From 782d1528c058f6afa58e159ff5d545f3264c75b1 Mon Sep 17 00:00:00 2001 From: Thomas Rouch <35526351+ThomasParistech@users.noreply.github.com> Date: Wed, 6 Nov 2024 14:41:16 +0100 Subject: [PATCH] feat: Add PDF Download option (#56) --- .devcontainer/packages.txt | 1 + .devcontainer/requirements.txt | 1 + .vscode/.mypy.ini | 3 +++ pretty_gpx/ui/pages/template/ui_manager.py | 20 +++++++++++++---- pretty_gpx/ui/pages/template/ui_plot.py | 25 ++++++++++++++++++++-- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/.devcontainer/packages.txt b/.devcontainer/packages.txt index 77a8510..b9e0ec4 100644 --- a/.devcontainer/packages.txt +++ b/.devcontainer/packages.txt @@ -2,6 +2,7 @@ git gdal-bin libgdal-dev build-essential +libcairo2 libgl1 libglib2.0-0 tk \ No newline at end of file diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index e6727f7..2c942ee 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -1,3 +1,4 @@ +cairosvg==2.7.1 dem-stitcher==2.5.5 dnspython==2.6.1 geopy==2.4.1 diff --git a/.vscode/.mypy.ini b/.vscode/.mypy.ini index 8b60fdb..0d1161a 100644 --- a/.vscode/.mypy.ini +++ b/.vscode/.mypy.ini @@ -18,6 +18,9 @@ ignore_missing_imports = True [mypy-tqdm.*] ignore_missing_imports = True +[mypy-cairosvg.*] +ignore_missing_imports = True + [mypy-cv2.*] ignore_missing_imports = True diff --git a/pretty_gpx/ui/pages/template/ui_manager.py b/pretty_gpx/ui/pages/template/ui_manager.py index 6537621..667946b 100644 --- a/pretty_gpx/ui/pages/template/ui_manager.py +++ b/pretty_gpx/ui/pages/template/ui_manager.py @@ -2,6 +2,7 @@ """Ui Manager.""" from dataclasses import dataclass from typing import Generic +from typing import Literal from typing import TypeVar from nicegui import events @@ -119,7 +120,9 @@ def __init__(self, cache: T) -> None: self.subclass_column = ui.column(align_items="center") col_3 = ui.column(align_items="center") - ui.button('Download', on_click=self.on_click_download) + with ui.row(align_items="center"): + ui.button('Download SVG', on_click=self.on_click_download_svg) + ui.button('Download PDF', on_click=self.on_click_download_pdf) self.params_to_hide.visible = False ### @@ -209,13 +212,22 @@ def on_dark_mode_switch_change(self, e: events.ValueChangeEventArguments) -> Non else: self.theme = self.theme.change(LightTheme.get_mapping()) - async def on_click_download(self) -> None: + async def on_click_download_svg(self) -> None: """Asynchronously render the high resolution poster and download it as SVG.""" svg_bytes = await self.render_download_svg_bytes() + self.download(svg_bytes, "svg") + async def on_click_download_pdf(self) -> None: + """Asynchronously render the high resolution poster and download it as PDF.""" + svg_bytes = await self.render_download_svg_bytes() + pdf_bytes = await self.plot.svg_to_pdf_bytes(svg_bytes) + self.download(pdf_bytes, "pdf") + + def download(self, data: bytes, ext: Literal["svg", "pdf"]) -> None: + """Download the high resolution poster.""" basename = "poster" title = self.title.value if title: basename += f"_{sanitize_filename(title.replace(' ', '_'))}" - ui.download(svg_bytes, f'{basename}.svg') - logger.info("Poster Downloaded") + ui.download(data, f'{basename}.{ext}') + logger.info(f"{ext.upper()} Poster Downloaded") diff --git a/pretty_gpx/ui/pages/template/ui_plot.py b/pretty_gpx/ui/pages/template/ui_plot.py index 8efa2c2..c1fda5a 100644 --- a/pretty_gpx/ui/pages/template/ui_plot.py +++ b/pretty_gpx/ui/pages/template/ui_plot.py @@ -1,10 +1,12 @@ #!/usr/bin/python3 """Ui Plot.""" import base64 +import io from collections.abc import Callable from io import BytesIO from typing import TypeVar +import cairosvg import matplotlib import matplotlib.pyplot as plt from matplotlib.axes import Axes @@ -39,6 +41,7 @@ def __init__(self, visible: bool) -> None: with ui.card().classes(f'w-[{W_DISPLAY_PIX}px]').style(f'{BOX_SHADOW_STYLE};') as self.card: self.img = ui.image() self.card.visible = visible + self.svg_bytes: bytes | None = None @staticmethod @profile_parallel @@ -61,19 +64,37 @@ def draw_svg(func: Callable[[Figure, Axes, T], None], data: T) -> bytes: async def update_preview(self, draw_func: Callable[[Figure, Axes, T], None], data: T) -> None: """Draw the figure and rasterize it to update the preview.""" + self.svg_bytes = None with UiWaitingModal("Updating Preview"): self.img.source = await run_cpu_bound(UiPlot.draw_png, draw_func, data) async def render_svg(self, draw_func: Callable[[Figure, Axes, T], None], data: T) -> bytes: """Draw the figure and return the SVG bytes.""" - with UiWaitingModal("Rendering SVG"): - return await run_cpu_bound(UiPlot.draw_svg, draw_func, data) + if self.svg_bytes is None: + with UiWaitingModal("Rendering Vectorized Poster"): + self.svg_bytes = await run_cpu_bound(UiPlot.draw_svg, draw_func, data) + + return self.svg_bytes + + async def svg_to_pdf_bytes(self, svg_bytes: bytes) -> bytes: + """Convert SVG bytes to PDF bytes.""" + with UiWaitingModal("Converting to PDF"): + return await run_cpu_bound(svg_to_pdf_bytes, svg_bytes) def make_visible(self) -> None: """Make the layout visible.""" self.card.visible = True +@profile_parallel +def svg_to_pdf_bytes(svg_bytes: bytes) -> bytes: + """Convert SVG bytes to PDF bytes.""" + pdf_bytes = io.BytesIO() + cairosvg.svg2pdf(bytestring=svg_bytes, write_to=pdf_bytes) + pdf_bytes.seek(0) + return pdf_bytes.read() + + @profile def fig_to_rasterized_base64(fig: Figure, dpi: int) -> str: """Convert a Matplotlib figure to a rasterized PNG in base64."""