diff options
| -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] |
