diff options
| author | Daniel Schadt <kingdread@gmx.de> | 2025-11-20 22:16:35 +0100 |
|---|---|---|
| committer | Daniel Schadt <kingdread@gmx.de> | 2025-11-20 22:16:35 +0100 |
| commit | ebea732518a30e959d0a762929504327dbee97d4 (patch) | |
| tree | 5648d7255cc06e65006dbdfb2872cc0738c2e5c8 | |
| parent | 8f0e56a9325d9b0b663c67fc7fe795b1de370778 (diff) | |
| download | fietsboek-ebea732518a30e959d0a762929504327dbee97d4.tar.gz fietsboek-ebea732518a30e959d0a762929504327dbee97d4.tar.bz2 fietsboek-ebea732518a30e959d0a762929504327dbee97d4.zip | |
initial PDF generation
This adds initial functionality to render PDF overviews of tracks. I was
pondering to use reportlab and do it completely in Python, but the
effort of doing proper layouting seemed to much. Add to that the fact
that Typst has much nicer typesetting, it seems like a no-brainer to use
it.
| -rw-r--r-- | fietsboek/pdf-assets/Nunito.ttf | bin | 0 -> 132200 bytes | |||
| -rw-r--r-- | fietsboek/pdf-assets/overview.typ | 27 | ||||
| -rw-r--r-- | fietsboek/pdf.py | 199 | ||||
| -rw-r--r-- | fietsboek/routes.py | 5 | ||||
| -rw-r--r-- | fietsboek/trackmap.py | 4 | ||||
| -rw-r--r-- | fietsboek/views/detail.py | 12 | ||||
| -rw-r--r-- | poetry.lock | 31 | ||||
| -rw-r--r-- | pyproject.toml | 3 |
8 files changed, 275 insertions, 6 deletions
diff --git a/fietsboek/pdf-assets/Nunito.ttf b/fietsboek/pdf-assets/Nunito.ttf Binary files differnew file mode 100644 index 0000000..be80c3f --- /dev/null +++ b/fietsboek/pdf-assets/Nunito.ttf diff --git a/fietsboek/pdf-assets/overview.typ b/fietsboek/pdf-assets/overview.typ new file mode 100644 index 0000000..0054483 --- /dev/null +++ b/fietsboek/pdf-assets/overview.typ @@ -0,0 +1,27 @@ +#set page(margin: 1cm) +#set text(font: "Nunito") + +#show heading.where(level: 1): set align(center) +#show heading.where(level: 1): set text(size: 20pt) + +#let rowHead(body) = strong(body) + +#heading[{{ title | typst_escape }}] + +#text(baseline: -1pt, emoji.person) +{% for person in people -%} +#strong[{{ person | typst_escape }}]{% if not loop.last %}, {% endif %} +{%- endfor %} + +#image("mapimage.png") + +#table( + columns: (50%, 50%), + stroke: none, + fill: (_, y) => if calc.odd(y) { rgb("#efefef") } else { none }, + {% for name, value in table -%} + rowHead[{{ name | typst_escape }}], [{{ value | typst_escape }}], + {% endfor %} +) + +{{ description | md_to_typst }} diff --git a/fietsboek/pdf.py b/fietsboek/pdf.py new file mode 100644 index 0000000..a6b40c9 --- /dev/null +++ b/fietsboek/pdf.py @@ -0,0 +1,199 @@ +import html.parser +import importlib.resources +import io +import logging +import tempfile +from dataclasses import dataclass +from itertools import chain +from pathlib import Path + +import jinja2 +import typst +from babel.dates import format_datetime +from babel.numbers import format_decimal +from pyramid.i18n import Localizer +from pyramid.i18n import TranslationString as _ + +from . import trackmap, util +from .config import TileLayerConfig +from .models import Track +from .models.track import TrackWithMetadata +from .views.tileproxy import TileRequester + +LOGGER = logging.getLogger(__name__) +TEMP_PREFIX = "fietsboek-typst-" +IMAGE_WIDTH = 900 +IMAGE_HEIGHT = 300 +# See https://typst.app/docs/reference/syntax/ +TO_ESCAPE = { + "$", + "#", + "[", + "]", + "*", + "_", + "`", + "<", + ">", + "@", + "=", + "-", + "+", + "/", + "\\", +} + + +class HtmlToTypst(html.parser.HTMLParser): + def __init__(self, out): + super().__init__() + self.out = out + + def handle_data(self, data): + self.out.write(typst_escape(data)) + + def handle_starttag(self, tag, attrs): + if tag == "strong": + self.out.write("#strong[") + elif tag == "em": + self.out.write("#emph[") + elif tag == "a": + href = "" + for key, val in attrs: + if key == "href": + href = val + self.out.write(f"#link({typst_string(href)})[") + elif tag == "ul": + self.out.write("#list(") + elif tag == "ol": + self.out.write("#enum(") + elif tag == "li": + self.out.write("[") + elif tag == "h1": + self.out.write("#heading(level: 1)[") + elif tag == "h2": + self.out.write("#heading(level: 2)[") + elif tag == "h3": + self.out.write("#heading(level: 3)[") + elif tag == "h4": + self.out.write("#heading(level: 4)[") + elif tag == "h5": + self.out.write("#heading(level: 5)[") + elif tag == "h6": + self.out.write("#heading(level: 6)[") + + def handle_endtag(self, tag): + if tag == "p": + self.out.write("\n\n") + elif tag == "strong": + self.out.write("]") + elif tag == "em": + self.out.write("]") + elif tag == "a": + self.out.write("]") + elif tag == "ul": + self.out.write(")") + elif tag == "ol": + self.out.write(")") + elif tag == "li": + self.out.write("],") + elif tag in {"h1", "h2", "h3", "h4", "h5", "h6"}: + self.out.write("]") + + +def typst_string(value: str) -> str: + return '"' + "".join(f"\\u{{{ord(char):x}}}" for char in value) + '"' + + +def typst_escape(value: str) -> str: + return "".join("\\" + char if char in TO_ESCAPE else char for char in value) + + +def md_to_typst(value: str) -> str: + html = util.safe_markdown(value).unescape() + buffer = io.StringIO() + parser = HtmlToTypst(buffer) + parser.feed(html) + return buffer.getvalue() + + +def draw_map(track: Track, requester: TileRequester, tile_layer: TileLayerConfig, outfile: Path): + map_image = trackmap.render( + track.path(), + tile_layer, + requester, + size=(IMAGE_WIDTH, IMAGE_HEIGHT), + ) + map_image.save(str(outfile)) + + +def generate( + track: Track, requester: TileRequester, tile_layer: TileLayerConfig, localizer: Localizer +) -> bytes: + """Generate a PDF representation for the given track.""" + env = jinja2.Environment( + loader=jinja2.PackageLoader("fietsboek", "pdf-assets"), + autoescape=False, + ) + env.filters["typst_escape"] = typst_escape + env.filters["md_to_typst"] = md_to_typst + + twm = TrackWithMetadata(track) + template = env.get_template("overview.typ") + translate = localizer.translate + locale = localizer.locale_name + placeholders = { + "title": track.title or track.date.strftime("%Y-%m-%d %H:%M"), + "people": [person.name for person in chain([track.owner], track.tagged_people)], + "table": [ + (translate(_("pdf.table.date")), format_datetime(track.date, locale=locale)), + ( + translate(_("pdf.table.length")), + "{} km".format(format_decimal(twm.length / 1000, locale=locale)), + ), + ( + translate(_("pdf.table.uphill")), + "{} m".format(format_decimal(twm.uphill, locale=locale)), + ), + ( + translate(_("pdf.table.downhill")), + "{} m".format(format_decimal(twm.downhill, locale=locale)), + ), + (translate(_("pdf.table.moving_time")), str(twm.moving_time)), + (translate(_("pdf.table.stopped_time")), str(twm.stopped_time)), + ( + translate(_("pdf.table.max_speed")), + "{} km/h".format(format_decimal(util.mps_to_kph(twm.max_speed), locale=locale)), + ), + ( + translate(_("pdf.table.avg_speed")), + "{} km/h".format(format_decimal(util.mps_to_kph(twm.avg_speed), locale=locale)), + ), + ], + "description": track.description, + } + + with tempfile.TemporaryDirectory(prefix=TEMP_PREFIX) as temp_dir: + LOGGER.debug("New PDF generation in %s", temp_dir) + temp_dir = Path(temp_dir) + + font_data = importlib.resources.read_binary("fietsboek", "pdf-assets/Nunito.ttf") + (temp_dir / "Nunito.ttf").write_bytes(font_data) + + draw_map(track, requester, tile_layer, temp_dir / "mapimage.png") + LOGGER.debug("%s: map drawn", temp_dir) + + rendered = template.render(placeholders) + LOGGER.debug("%s: typst template rendered", temp_dir) + + (temp_dir / "overview.typ").write_text(rendered) + pdf_bytes = typst.compile( + str(temp_dir / "overview.typ"), + font_paths=[str(temp_dir)], + ) + LOGGER.debug("%s: PDF rendering complete", temp_dir) + + return pdf_bytes + + +__all__ = ["generate"] diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 4286e92..b8a0113 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -51,6 +51,11 @@ def includeme(config): "/track/{track_id}/preview", factory="fietsboek.models.Track.factory", ) + config.add_route( + "track-pdf", + "/track/{track_id}/index.pdf", + factory="fietsboek.models.Track.factory", + ) config.add_route("badge", "/badge/{badge_id}", factory="fietsboek.models.Badge.factory") diff --git a/fietsboek/trackmap.py b/fietsboek/trackmap.py index 9854211..4280e0c 100644 --- a/fietsboek/trackmap.py +++ b/fietsboek/trackmap.py @@ -119,7 +119,7 @@ class TrackMapRenderer: draw.line(coords, fill=self.color, width=self.line_width, joint="curve") -def render(track: geo.Path, layer: TileLayerConfig, requester: TileRequester) -> Image.Image: +def render(track: geo.Path, layer: TileLayerConfig, requester: TileRequester, size: (int, int) = (300, 300)) -> Image.Image: """Shorthand to construct a :class:`TrackMapRenderer` and render the preview. :param track: Track to render. @@ -127,7 +127,7 @@ def render(track: geo.Path, layer: TileLayerConfig, requester: TileRequester) -> :param requester: The requester which will be used to request the tiles. :return: The image containing the rendered preview. """ - return TrackMapRenderer(track, requester, (300, 300), layer).render() + return TrackMapRenderer(track, requester, size, layer).render() __all__ = ["to_web_mercator", "TrackMapRenderer", "render"] diff --git a/fietsboek/views/detail.py b/fietsboek/views/detail.py index 8ca7836..b1d3c74 100644 --- a/fietsboek/views/detail.py +++ b/fietsboek/views/detail.py @@ -19,7 +19,7 @@ from pyramid.response import FileResponse, Response from pyramid.view import view_config from sqlalchemy import select -from .. import models, trackmap, util +from .. import models, pdf, trackmap, util from ..models.track import Track, TrackWithMetadata from .tileproxy import ITileRequester @@ -286,6 +286,15 @@ def track_map(request: Request): return response +@view_config(route_name="track-pdf", permission="track.view") +def track_pdf(request: Request): + loader: ITileRequester = request.registry.getUtility(ITileRequester) + layer = request.config.public_tile_layers()[0] + pdf_bytes = pdf.generate(request.context, loader, layer, request.localizer) + response = Response(pdf_bytes, content_type="application/pdf") + return response + + __all__ = [ "details", "gpx", @@ -295,4 +304,5 @@ __all__ = [ "image", "add_comment", "track_map", + "track_pdf", ] diff --git a/poetry.lock b/poetry.lock index c5ba468..6f9d615 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2494,6 +2494,33 @@ files = [ typing-extensions = ">=4.12.0" [[package]] +name = "typst" +version = "0.14.1" +description = "" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "typst-0.14.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:259e31fac599b21187384942a806778be35d2391362e7f3bf3970b74a1581008"}, + {file = "typst-0.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b14efb183e71aac2f15d83a11c4d90d474c07ed4bb021a574cdfcffe5fdb3146"}, + {file = "typst-0.14.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f695316ff4aab056d49dec04c4f34a6a29a26694ef8fdc85b1eafe8c7b589ec4"}, + {file = "typst-0.14.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:af9dd117af2f0c808cb1937acb9c88dda4895d9f5670fdff8c8a67281775c49c"}, + {file = "typst-0.14.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e86d766f13590b67fb36764ff0e5e89710a7e2fea8ecdc41fc9bf38aa6de1c9"}, + {file = "typst-0.14.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2178364f3787cefd2bfc2473cb2f37b7142b2ef6db7a8909344a03de506577a5"}, + {file = "typst-0.14.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9824868688a011b645275f3252a217e18344f043683fa2a34a0c9e862e7cecd5"}, + {file = "typst-0.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c6acfb92cb1cd55ca7ace1e3cb4244de7d6aea43def0f4fb0a86bc4acc546cb0"}, + {file = "typst-0.14.1-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4498657165994502aaba75364c5830c44509bd06d28b1b1da2d0de29c5271a65"}, + {file = "typst-0.14.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:386fdd45862d69f53960576a9a8834079bef1c0cea7bcd6e31af8f7f9b3f1a2c"}, + {file = "typst-0.14.1-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8a249d78debf8446faa72d928762fbfab3ca934b549b6122405533bffc3666c"}, + {file = "typst-0.14.1-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77a16fd700fd1e9108b8e5b25529e09f42e9a9d73163bba14010ae7864d43c42"}, + {file = "typst-0.14.1-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b001ce9193650e3d4d743c1f098f5ad51ab3aa8fc22d7cf2a1078e3092d554a2"}, + {file = "typst-0.14.1-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6488e56b6991a7fa72dc76326420e48af88b8d5524f467c7ec9ac50058d0ae95"}, + {file = "typst-0.14.1-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ca7303acec9fcbf06d949d8c7c1d5ccb986c1ac96a8fbdf08e25ac338df4a92"}, + {file = "typst-0.14.1-cp38-abi3-win_amd64.whl", hash = "sha256:9a327226c639510d163578f454062f80505332a91f7599eb8f72d51f172a0f19"}, + {file = "typst-0.14.1.tar.gz", hash = "sha256:8424387c4ea709caf0dcd54c44f736f86cdde421ac86f9a5d2fa76ba4978b50e"}, +] + +[[package]] name = "urllib3" version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." @@ -2675,5 +2702,5 @@ hittekaart = ["hittekaart-py"] [metadata] lock-version = "2.1" -python-versions = ">=3.11" -content-hash = "d845a9c1ae4a96f42c85dd2d73f364f22b4bfe14779bf0ad902557faffa6a7db" +python-versions = ">=3.11, <4" +content-hash = "1f96ea2a459f39a0179e0593f409b622199abecd494359be15b689ff18b13c3b" diff --git a/pyproject.toml b/pyproject.toml index 891b5e3..bb01629 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ { name = "Daniel Schadt", email = "fietsboek@kingdread.de>" }, ] keywords = ["web", "gpx"] -requires-python = ">=3.11" +requires-python = ">=3.11, <4" dependencies = [ "pyramid (>=2, <3)", "pyramid_jinja2 (>=2.10, <3.0)", @@ -42,6 +42,7 @@ dependencies = [ "click-option-group (>=0.5.5, <0.6.0)", "fitparse (>=1.2.0, <2.0.0)", "pillow (>=12.0.0, <13.0.0)", + "typst (>=0.14.1,<0.15.0)", ] [project.urls] |
