aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2025-11-20 22:16:35 +0100
committerDaniel Schadt <kingdread@gmx.de>2025-11-20 22:16:35 +0100
commitebea732518a30e959d0a762929504327dbee97d4 (patch)
tree5648d7255cc06e65006dbdfb2872cc0738c2e5c8
parent8f0e56a9325d9b0b663c67fc7fe795b1de370778 (diff)
downloadfietsboek-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.ttfbin0 -> 132200 bytes
-rw-r--r--fietsboek/pdf-assets/overview.typ27
-rw-r--r--fietsboek/pdf.py199
-rw-r--r--fietsboek/routes.py5
-rw-r--r--fietsboek/trackmap.py4
-rw-r--r--fietsboek/views/detail.py12
-rw-r--r--poetry.lock31
-rw-r--r--pyproject.toml3
8 files changed, 275 insertions, 6 deletions
diff --git a/fietsboek/pdf-assets/Nunito.ttf b/fietsboek/pdf-assets/Nunito.ttf
new file mode 100644
index 0000000..be80c3f
--- /dev/null
+++ b/fietsboek/pdf-assets/Nunito.ttf
Binary files differ
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]