Files
html-to-pdf/app/pdf/styles.py
T
JiriUhlirandClaude Opus 5 156289fe2d 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>
2026-08-27 14:50:10 +02:00

116 lines
3.4 KiB
Python

"""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"
)