aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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]