Implementace prevodu HTML na PDF
Sluzba prijme adresu HTML dokumentu nebo HTML v tele requestu a vrati PDF. Navrzena pro dokumenty o stovkach az tisicich stranek. Rendering: - WeasyPrint jako vychozi engine, spravne CSS Paged Media, nizka pametova narocnost, bez JavaScriptu - Chromium pres Playwright pro dokumenty dokreslovane skripty - rezim auto s detekci skriptu a fallbackem pri selhani WeasyPrintu Velke dokumenty: - deleni na casti na strukturalnich hranicich, rez nikdy uvnitr tabulky nebo odstavce - dvoupruchodovy render obsahu se skutecnymi cisly stranek, pozice nadpisu se ctou z kotev hlasenych u kazde stranky - cislovani stranek bud pres CSS countery, nebo pres cislovaci vrstvu nastampovanou na hotove PDF, rozmer stranky se cte z vysledneho souboru - Chromium se restartuje po N jobech, nikdy vsak behem beziciho renderu API: - POST /convert synchronne, POST /jobs asynchronne se sledovanim stavu, stahovanim vysledku, rusenim a volitelnym callbackem - GET /health s overenim dostupnosti obou enginu a stavem fronty - OpenAPI respektuje prefix reverse proxy pres root_path Bezpecnost a provoz: - SSRF kontrola po DNS resolvu, na kazdem presmerovani a u vsech pozadavku prohlizece - nedostupne assety render nezastavi, ale hlasi se v odpovedi i v logu - fronta s omezenym poctem workeru, rozpracovane joby se pri ukonceni oznaci jako failed, nezmizi potichu - strukturovane JSON logovani s job_id - vsechny limity vypnute ve vychozim stavu Dockerfile je dvoufazovy, obsahuje zavislosti WeasyPrintu, Chromium a fonty s ceskou diakritikou. Autentizace zamerne neni implementovana, zpusob predavani neni domluveny. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3cc8f418b
commit
156289fe2d
@@ -0,0 +1,115 @@
|
||||
"""Generation of the page stylesheet injected into the source document."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from ..models import PageNumbers, PageSettings
|
||||
|
||||
NAMED_SIZE = re.compile(r"^[A-Za-z][A-Za-z0-9]*$")
|
||||
|
||||
MARGIN_BOXES = {
|
||||
"top-left": "@top-left",
|
||||
"top-center": "@top-center",
|
||||
"top-right": "@top-right",
|
||||
"bottom-left": "@bottom-left",
|
||||
"bottom-center": "@bottom-center",
|
||||
"bottom-right": "@bottom-right",
|
||||
}
|
||||
|
||||
PLACEHOLDER = re.compile(r"(\{page\}|\{pages\})")
|
||||
|
||||
|
||||
def page_size_value(page: PageSettings) -> str:
|
||||
fmt = page.format.strip()
|
||||
if NAMED_SIZE.match(fmt):
|
||||
return f"{fmt} {page.orientation}"
|
||||
# Explicit dimensions already carry the orientation.
|
||||
return fmt
|
||||
|
||||
|
||||
def margin_shorthand(page: PageSettings) -> str:
|
||||
m = page.margin
|
||||
return f"{m.top} {m.right} {m.bottom} {m.left}"
|
||||
|
||||
|
||||
def css_content_value(fmt: str, total_pages: int | None) -> str:
|
||||
"""Turn "{page} / {pages}" into a CSS content value.
|
||||
|
||||
When total_pages is known the total is written as a literal, otherwise the
|
||||
CSS counter(pages) is used.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
for token in PLACEHOLDER.split(fmt):
|
||||
if token == "{page}":
|
||||
parts.append("counter(page)")
|
||||
elif token == "{pages}":
|
||||
parts.append(str(total_pages) if total_pages is not None else "counter(pages)")
|
||||
elif token:
|
||||
escaped = token.replace("\\", "\\\\").replace('"', '\\"')
|
||||
parts.append(f'"{escaped}"')
|
||||
return " ".join(parts) if parts else '""'
|
||||
|
||||
|
||||
def build_page_css(
|
||||
page: PageSettings,
|
||||
page_numbers: PageNumbers | None = None,
|
||||
total_pages: int | None = None,
|
||||
outline: bool = True,
|
||||
) -> str:
|
||||
"""Stylesheet applied on top of the document styles."""
|
||||
|
||||
rules = [
|
||||
"@page {",
|
||||
f" size: {page_size_value(page)};",
|
||||
f" margin: {margin_shorthand(page)};",
|
||||
]
|
||||
|
||||
if page_numbers is not None and page_numbers.enabled:
|
||||
box = MARGIN_BOXES[page_numbers.position]
|
||||
rules.append(f" {box} {{")
|
||||
rules.append(f" content: {css_content_value(page_numbers.format, total_pages)};")
|
||||
rules.append(" font-size: 9pt;")
|
||||
rules.append(" color: #444;")
|
||||
rules.append(" }")
|
||||
|
||||
rules.append("}")
|
||||
|
||||
if not outline:
|
||||
rules.append("h1, h2, h3, h4, h5, h6 { bookmark-level: none; }")
|
||||
|
||||
return "\n".join(rules)
|
||||
|
||||
|
||||
def build_overlay_css(
|
||||
width_pt: float,
|
||||
height_pt: float,
|
||||
page: PageSettings,
|
||||
page_numbers: PageNumbers,
|
||||
total_pages: int,
|
||||
) -> str:
|
||||
"""Stylesheet for the transparent numbering layer merged onto the final PDF.
|
||||
|
||||
The size comes from the produced PDF itself, so the overlay always matches
|
||||
even when the source document declares its own @page size.
|
||||
"""
|
||||
box = MARGIN_BOXES[page_numbers.position]
|
||||
reset = ""
|
||||
if page_numbers.start_at != 1:
|
||||
reset = f"body {{ counter-reset: page {page_numbers.start_at - 1}; }}\n"
|
||||
|
||||
return (
|
||||
f"@page {{\n"
|
||||
f" size: {width_pt:.2f}pt {height_pt:.2f}pt;\n"
|
||||
f" margin: {margin_shorthand(page)};\n"
|
||||
f" {box} {{\n"
|
||||
f" content: {css_content_value(page_numbers.format, total_pages)};\n"
|
||||
f" font-size: 9pt;\n"
|
||||
f" color: #444;\n"
|
||||
f" }}\n"
|
||||
f"}}\n"
|
||||
f"{reset}"
|
||||
f"body {{ margin: 0; }}\n"
|
||||
f".pdf-page-slot {{ height: 1px; break-after: page; }}\n"
|
||||
f".pdf-page-slot:last-child {{ break-after: auto; }}\n"
|
||||
)
|
||||
Reference in New Issue
Block a user