diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4748569..0cddd0d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,8 +5,12 @@ on: tags: - "[0-9]+.[0-9]+.[0-9]+" +permissions: + contents: read + jobs: - publish: + # region Publish Snapr + publish-snapr: name: Publish - `crates.io` runs-on: ubuntu-latest steps: @@ -23,4 +27,212 @@ jobs: env: CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_TOKEN }} run: | - cargo publish --all-features --verbose -p snapr \ No newline at end of file + cargo publish --all-features --verbose -p snapr + + # region Upload Linux Wheels + upload-linux-wheels: + strategy: + matrix: + include: + - identifier: x86_64 + target: x86_64 + + - identifier: x86 + target: x86 + + - identifier: ARM64 + target: aarch64 + + name: Upload Wheels - Linux - ${{ matrix.identifier }} + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + manylinux: auto + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py + + - name: Upload Wheels - `${{matrix.identifier}}` + uses: actions/upload-artifact@v4 + with: + name: wheels-linux-${{ matrix.target }} + path: dist + + # region Upload Musl Wheels + upload-musl-wheels: + strategy: + matrix: + include: + - identifier: x86_64 + target: x86_64 + + - identifier: x86 + target: x86 + + - identifier: ARM64 + target: aarch64 + + name: Upload Wheels - Musl - ${{ matrix.identifier }} + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + manylinux: musllinux_1_2 + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py + + - name: Upload Wheels - `${{matrix.identifier}}` + uses: actions/upload-artifact@v4 + with: + name: wheels-musl-${{ matrix.target }} + path: dist + + # region Upload Windows Wheels + upload-windows-wheels: + strategy: + matrix: + include: + - identifier: x64 + target: x64 + + - identifier: x86 + target: x86 + + name: Upload Wheels - Windows - ${{ matrix.identifier }} + runs-on: windows-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + architecture: ${{ matrix.target }} + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py + + - name: Upload Wheels - `${{matrix.identifier}}` + uses: actions/upload-artifact@v4 + with: + name: wheels-windows-${{ matrix.target }} + path: dist + + # region Upload MacOS Wheels + upload-macos-wheels: + strategy: + matrix: + include: + - identifier: x86_64 + os: macos-12 + target: x86_64 + + - identifier: ARM64 + os: macos-14 + target: aarch64 + + name: Upload Wheels - MacOS - ${{ matrix.identifier }} + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py + + - name: Upload Wheels - `${{matrix.identifier}}` + uses: actions/upload-artifact@v4 + with: + name: wheels-macos-${{ matrix.target }} + path: dist + + # region Upload Source Distribution + upload-source-distribution: + name: Upload Source Distribution + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Build Source Distribution + uses: PyO3/maturin-action@v1 + with: + args: --out dist + command: sdist + working-directory: snapr-py + + - name: Upload Source Distribution + uses: actions/upload-artifact@v4 + with: + name: wheels-sdist + path: dist + + # region Publish Snapr.py + publish-snapr-py: + name: Publish - `pypi.org` + runs-on: ubuntu-latest + needs: + - upload-linux-wheels + - upload-macos-wheels + - upload-musl-wheels + - upload-source-distribution + - upload-windows-wheels + permissions: + id-token: write + contents: write + attestations: write + steps: + - name: Download Artifacts + uses: actions/download-artifact@v4 + + - name: Generate Artifact Attestation + uses: actions/attest-build-provenance@v1 + with: + subject-path: "wheels-*/*" + + - name: Publish - `pypi.org` + uses: PyO3/maturin-action@v1 + env: + MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + with: + args: --non-interactive --skip-existing wheels-*/* + command: upload + working-directory: snapr-py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 0f7a75c..223241b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -4,6 +4,7 @@ on: pull_request: jobs: + # region Builds verify-builds: strategy: fail-fast: false @@ -35,6 +36,7 @@ jobs: cargo build --all-features --release --verbose --workspace cargo build --all-features --verbose --workspace + # region Lints verify-lints: strategy: fail-fast: false @@ -70,6 +72,125 @@ jobs: run: | cargo clippy --no-deps --all-features -- -Dwarnings + # region Linux Wheels + verify-linux-wheels: + strategy: + fail-fast: false + matrix: + include: + - identifier: x86_64 + target: x86_64 + + - identifier: x86 + target: x86 + + - identifier: ARM64 + target: aarch64 + + name: Verify - Wheels - Linux - ${{ matrix.identifier }} + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + manylinux: auto + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py + + # region MacOS Wheels + verify-macos-wheels: + strategy: + fail-fast: false + matrix: + include: + - identifier: x86_64 + os: macos-12 + target: x86_64 + + - identifier: ARM64 + os: macos-14 + target: aarch64 + + name: Verify - Wheels - MacOS - ${{ matrix.identifier }} + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py + + # region Musl Wheels + verify-musl-wheels: + strategy: + fail-fast: false + matrix: + include: + - identifier: x86_64 + target: x86_64 + + - identifier: x86 + target: x86 + + - identifier: ARM64 + target: aarch64 + + name: Verify - Wheels - Musl - ${{ matrix.identifier }} + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + manylinux: musllinux_1_2 + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py + + # region Source Distribution + verify-source-distribution: + name: Verify - Source Distribution + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Build Source Distribution + uses: PyO3/maturin-action@v1 + with: + args: --out dist + command: sdist + working-directory: snapr-py + + # region Tests verify-tests: strategy: fail-fast: false @@ -98,4 +219,36 @@ jobs: - name: Verify - `cargo test` run: | - cargo test --all-features --no-fail-fast --verbose --workspace \ No newline at end of file + cargo test --all-features --no-fail-fast --verbose --workspace + + # region Windows Wheels + verify-windows-wheels: + strategy: + fail-fast: false + matrix: + include: + - identifier: x64 + target: x64 + + - identifier: x86 + target: x86 + + name: Verify - Wheels - Windows - ${{ matrix.identifier }} + runs-on: windows-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + architecture: ${{ matrix.target }} + python-version: 3.x + + - name: Build Wheels + uses: PyO3/maturin-action@v1 + with: + args: --release --out dist --find-interpreter + sccache: "true" + target: ${{ matrix.target }} + working-directory: snapr-py diff --git a/Cargo.toml b/Cargo.toml index 60fb702..33998de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,13 +5,14 @@ members = [ "examples/open-street-maps", "examples/svg", "snapr", + "snapr-py", ] resolver = "2" [workspace.package] authors = ["c1m50c <58411864+c1m50c@users.noreply.github.com>"] license = "MIT" -version = "0.4.1" +version = "0.4.2" [workspace.dependencies] anyhow = "1.0.89" diff --git a/snapr-py/.gitignore b/snapr-py/.gitignore new file mode 100644 index 0000000..a61b91c --- /dev/null +++ b/snapr-py/.gitignore @@ -0,0 +1,75 @@ +/target + +# Byte-compiled / optimized / DLL files +__pycache__/ +.pytest_cache/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +.venv/ +env/ +bin/ +build/ +develop-eggs/ +dist/ +eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +include/ +man/ +venv/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt +pip-selfcheck.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.cache +nosetests.xml +coverage.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Rope +.ropeproject + +# Django stuff: +*.log +*.pot + +.DS_Store + +# Sphinx documentation +docs/_build/ + +# PyCharm +.idea/ + +# VSCode +.vscode/ + +# Pyenv +.python-version + +# uv +uv.lock \ No newline at end of file diff --git a/snapr-py/Cargo.toml b/snapr-py/Cargo.toml new file mode 100644 index 0000000..22f321e --- /dev/null +++ b/snapr-py/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "snapr-py" +edition = "2021" +publish = false + +# Shared Package Configuration +authors.workspace = true +license.workspace = true +version.workspace = true + +[lib] +name = "snapr" +crate-type = ["cdylib"] + +[dependencies] +anyhow = { workspace = true } +geo = { workspace = true } +image = { workspace = true } +pyo3 = "0.22.0" +snapr = { path = "../snapr" } diff --git a/snapr-py/README.md b/snapr-py/README.md new file mode 100644 index 0000000..b86fde0 --- /dev/null +++ b/snapr-py/README.md @@ -0,0 +1,49 @@ +# snapr.py + +Python bindings to the [`snapr`](https://crates.io/crates/snapr) library. + +Flexible and frictionless way to render snapshots of maps with stylized geometries. + +## Examples + +### Open Street Maps + +```py +from snapr import Geometry, Point, Snapr +import requests + +def tile_fetcher( + coords: list[tuple[int, int]], zoom: int +) -> list[tuple[int, int, bytearray]]: + tiles = list() + + for x, y in coords: + response = requests.get( + f"https://a.tile.osm.org/{zoom}/{x}/{y}.png", + headers={"User-Agent": "snapr.py"}, + ) + + tiles.append((x, y, bytearray(response.content))) + + return tiles + + +snapr = Snapr(tile_fetcher=tile_fetcher, zoom=15) + +geometries = [ + # Chimney Rock, Nebraska + # https://www.openstreetmap.org/search?lat=41.703811459356196&lon=-103.34835922605679 + Geometry.Point(Point(latitude=41.703811459356196, longitude=-103.34835922605679)), + # Chimney Rock Cemetery, Nebraska + # https://www.openstreetmap.org/search?lat=41.702909695820175&lon=-103.33250120288363 + Geometry.Point(Point(latitude=41.69996628239992, longitude=-103.34170814251178)), + # Chimney Rock Museum, Nebraska + # https://www.openstreetmap.org/search?lat=41.702909695820175&lon=-103.33250120288363 + Geometry.Point(Point(latitude=41.702909695820175, longitude=-103.33250120288363)), +] + +snapshot = snapr.generate_snapshot_from_geometries(geometries=geometries) + +with open("example.png", "wb") as image: + image.write(snapshot) +``` diff --git a/snapr-py/examples/point.py b/snapr-py/examples/point.py new file mode 100644 index 0000000..6dc1ab2 --- /dev/null +++ b/snapr-py/examples/point.py @@ -0,0 +1,38 @@ +import requests +from snapr import Geometry, Point, Snapr + + +def tile_fetcher( + coords: list[tuple[int, int]], zoom: int +) -> list[tuple[int, int, bytearray]]: + tiles = list() + + for x, y in coords: + response = requests.get( + f"https://a.tile.osm.org/{zoom}/{x}/{y}.png", + headers={"User-Agent": "snapr.py"}, + ) + + tiles.append((x, y, bytearray(response.content))) + + return tiles + + +snapr = Snapr(tile_fetcher=tile_fetcher, zoom=15) + +geometries = [ + # Chimney Rock, Nebraska + # https://www.openstreetmap.org/search?lat=41.703811459356196&lon=-103.34835922605679 + Geometry.Point(Point(latitude=41.703811459356196, longitude=-103.34835922605679)), + # Chimney Rock Cemetery, Nebraska + # https://www.openstreetmap.org/search?lat=41.702909695820175&lon=-103.33250120288363 + Geometry.Point(Point(latitude=41.69996628239992, longitude=-103.34170814251178)), + # Chimney Rock Museum, Nebraska + # https://www.openstreetmap.org/search?lat=41.702909695820175&lon=-103.33250120288363 + Geometry.Point(Point(latitude=41.702909695820175, longitude=-103.33250120288363)), +] + +snapshot = snapr.generate_snapshot_from_geometries(geometries=geometries) + +with open("example.png", "wb") as image: + image.write(snapshot) diff --git a/snapr-py/pyproject.toml b/snapr-py/pyproject.toml new file mode 100644 index 0000000..8ac412c --- /dev/null +++ b/snapr-py/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "snapr" +authors = [ + { name = "c1m50c", email = "58411864+c1m50c@users.noreply.github.com" } +] +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dynamic = ["version"] +license = "MIT" +readme = "README.md" +requires-python = ">=3.8" + +[project.urls] +Issues = "https://github.com/c1m50c/snapr/issues" +Repository = "https://github.com/c1m50c/snapr" + +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[tool.maturin] +features = ["pyo3/extension-module"] + +[tool.uv] +dev-dependencies = [ + "pip>=24.2", + "requests>=2.32.3", +] diff --git a/snapr-py/snapr.pyi b/snapr-py/snapr.pyi new file mode 100644 index 0000000..22bcd65 --- /dev/null +++ b/snapr-py/snapr.pyi @@ -0,0 +1,153 @@ +from typing import Callable + +# region lib.rs + +class SnaprError(Exception): ... + +class Snapr: + def __init__( + self, + tile_fetcher: Callable[ + [list[tuple[int, int]], int], list[tuple[int, int, bytearray]] + ], + tile_size: int = 256, + height: int = 600, + width: int = 800, + zoom: int | None = None, + ) -> None: ... + def generate_snapshot_from_geometry( + self, geometry: Geometry, styles: list[Style] = [] + ) -> bytearray: ... + def generate_snapshot_from_geometries( + self, geometries: list[Geometry], styles: list[Style] = [] + ) -> bytearray: ... + +# region geo.rs + +class Point: + def __init__(self, latitude: float, longitude: float) -> None: ... + +class Line: + def __init__( + self, start: Point | tuple[float, float], end: Point | tuple[float, float] + ) -> None: ... + +class LineString: + def __init__(self, points: list[Point | tuple[float, float]]) -> None: ... + +class Polygon: + def __init__(self, exterior: LineString, interiors: list[LineString]) -> None: ... + +class MultiPoint: + def __init__(self, points: list[Point | tuple[float, float]]) -> None: ... + +class MultiLineString: + def __init__(self, line_strings: list[LineString]) -> None: ... + +class MultiPolygon: + def __init__(self, polygons: list[Polygon]) -> None: ... + +class Rect: + def __init__( + self, corner_1: Point | tuple[float, float], corner_2: Point | tuple[float, float] + ) -> None: ... + +class Triangle: + def __init__( + self, a: Point | tuple[float, float], b: Point | tuple[float, float], c: Point | tuple[float, float] + ) -> None: ... + +class GeometryCollection: + def __init__(self, geometries: list[Geometry]) -> None: ... + +class Geometry: + @staticmethod + def Point(geometry: Point) -> Geometry: ... + @staticmethod + def Line(geometry: Line) -> Geometry: ... + @staticmethod + def LineString(geometry: LineString) -> Geometry: ... + @staticmethod + def Polygon(geometry: Polygon) -> Geometry: ... + @staticmethod + def MultiPoint(geometry: MultiPoint) -> Geometry: ... + @staticmethod + def MultiLineString(geometry: MultiLineString) -> Geometry: ... + @staticmethod + def MultiPolygon(geometry: MultiPolygon) -> Geometry: ... + @staticmethod + def GeometryCollection(geometry: GeometryCollection) -> Geometry: ... + @staticmethod + def Rect(geometry: Rect) -> Geometry: ... + @staticmethod + def Triangle(geometry: Triangle) -> Geometry: ... + +# region style.rs + +class Color: + def __init__(self, r: int, g: int, b: int, a: int) -> None: ... + +class ColorOptions: + def __init__( + self, + foreground: Color = Color(248, 248, 248, 255), + background: Color = Color(26, 26, 26, 255), + anti_alias: bool = True, + border: float | None = 1.0, + ) -> None: ... + +class Shape: + @staticmethod + def Circle(radius: float = 4.0) -> Shape: ... + +class Svg: + def __init__(self, svg: str, offset: tuple[int, int] = (0, 0)) -> None: ... + +class Representation: + @staticmethod + def Shape(shape: Shape) -> Representation: ... + @staticmethod + def Svg(svg: Svg) -> Representation: ... + +class Label: + def __init__( + self, + text: str, + color_options: ColorOptions = ColorOptions(), + font_family: str = "Arial", + font_size: float = 16.0, + offset: tuple[int, int] = (0, 0), + ) -> None: ... + +class PointStyle: + def __init__( + self, + color_options: ColorOptions = ColorOptions(), + representation: Representation = Representation.Shape(Shape.Circle()), + label: Label | None = None, + ) -> None: ... + +class LineStyle: + def __init__( + self, + color_options: ColorOptions = ColorOptions( + foreground=Color(196, 196, 196, 255), border=4.0 + ), + width: float = 3.0, + ) -> None: ... + +class PolygonStyle: + def __init__( + self, + color_options: ColorOptions = ColorOptions( + foreground=Color(248, 248, 248, 64), border=None + ), + ) -> None: ... + +class Style: + @staticmethod + def Point(style: PointStyle = PointStyle()) -> Style: ... + @staticmethod + def Line(style: LineStyle = LineStyle()) -> Style: ... + @staticmethod + def Polygon(style: PolygonStyle = PolygonStyle()) -> Style: ... diff --git a/snapr-py/src/geo.rs b/snapr-py/src/geo.rs new file mode 100644 index 0000000..58084f2 --- /dev/null +++ b/snapr-py/src/geo.rs @@ -0,0 +1,225 @@ +use std::ops::{Deref, DerefMut}; + +use pyo3::prelude::*; + +#[derive(Clone, Debug, PartialEq)] +#[pyclass(name = "Geometry")] +pub enum PyGeometry { + Point(PyPoint), + Line(PyLine), + LineString(PyLineString), + Polygon(PyPolygon), + MultiPoint(PyMultiPoint), + MultiLineString(PyMultiLineString), + MultiPolygon(PyMultiPolygon), + GeometryCollection(PyGeometryCollection), + Rect(PyRect), + Triangle(PyTriangle), +} + +#[allow(clippy::from_over_into)] +impl Into for PyGeometry { + fn into(self) -> geo::Geometry { + match self { + Self::Point(geometry) => geo::Geometry::Point(geometry.0), + Self::Line(geometry) => geo::Geometry::Line(geometry.0), + Self::LineString(geometry) => geo::Geometry::LineString(geometry.0), + Self::Polygon(geometry) => geo::Geometry::Polygon(geometry.0), + Self::MultiPoint(geometry) => geo::Geometry::MultiPoint(geometry.0), + Self::MultiLineString(geometry) => geo::Geometry::MultiLineString(geometry.0), + Self::MultiPolygon(geometry) => geo::Geometry::MultiPolygon(geometry.0), + Self::GeometryCollection(geometry) => geo::Geometry::GeometryCollection(geometry.0), + Self::Rect(geometry) => geo::Geometry::Rect(geometry.0), + Self::Triangle(geometry) => geo::Geometry::Triangle(geometry.0), + } + } +} + +#[derive(Clone, Debug, FromPyObject, PartialEq)] +pub enum PyPointOrTuple { + Point(PyPoint), + Tuple((f64, f64)), +} + +#[allow(clippy::from_over_into)] +impl Into for PyPointOrTuple { + fn into(self) -> PyPoint { + match self { + Self::Point(point) => point, + Self::Tuple((x, y)) => PyPoint::new(x, y), + } + } +} + +#[allow(clippy::from_over_into)] +impl Into> for PyPointOrTuple { + fn into(self) -> geo::Point { + let py_point = >::into(self); + py_point.0 + } +} + +macro_rules! impl_geo_wrapper { + ($base: ident, $variant: ident, $class: literal) => { + #[derive(Clone, Debug, PartialEq)] + #[pyclass(name = $class)] + pub struct $variant(geo::$base); + + impl Deref for $variant { + type Target = geo::$base; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for $variant { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl From> for $variant { + fn from(value: geo::$base) -> Self { + Self(value) + } + } + + #[allow(clippy::from_over_into)] + impl Into> for $variant { + fn into(self) -> geo::$base { + self.0 + } + } + + #[allow(clippy::from_over_into)] + impl Into for $variant { + fn into(self) -> PyGeometry { + PyGeometry::$base(self) + } + } + }; +} + +impl_geo_wrapper!(Point, PyPoint, "Point"); + +#[pymethods] +impl PyPoint { + #[new] + fn new(latitude: f64, longitude: f64) -> Self { + let point = geo::point!(x: latitude, y: longitude); + Self(point) + } +} + +impl_geo_wrapper!(Line, PyLine, "Line"); + +#[pymethods] +impl PyLine { + #[new] + fn new(start: PyPointOrTuple, end: PyPointOrTuple) -> Self { + Self(geo::Line::new::(start.into(), end.into())) + } +} + +impl_geo_wrapper!(LineString, PyLineString, "LineString"); + +#[pymethods] +impl PyLineString { + #[new] + fn new(points: Vec) -> Self { + let coords = points + .into_iter() + .map(|x| >::into(x).into()) + .collect(); + + Self(geo::LineString::new(coords)) + } +} + +impl_geo_wrapper!(Polygon, PyPolygon, "Polygon"); + +#[pymethods] +impl PyPolygon { + #[new] + fn new(exterior: PyLineString, interiors: Vec) -> Self { + let interiors = interiors.into_iter().map(PyLineString::into).collect(); + Self(geo::Polygon::new(exterior.0, interiors)) + } +} + +impl_geo_wrapper!(MultiPoint, PyMultiPoint, "MultiPoint"); + +#[pymethods] +impl PyMultiPoint { + #[new] + fn new(points: Vec) -> Self { + let points = points.into_iter().map(PyPointOrTuple::into).collect(); + Self(geo::MultiPoint::new(points)) + } +} + +impl_geo_wrapper!(MultiLineString, PyMultiLineString, "MultiLineString"); + +#[pymethods] +impl PyMultiLineString { + #[new] + fn new(line_strings: Vec) -> Self { + let line_strings = line_strings.into_iter().map(PyLineString::into).collect(); + Self(geo::MultiLineString::new(line_strings)) + } +} + +impl_geo_wrapper!(MultiPolygon, PyMultiPolygon, "MultiPolygon"); + +#[pymethods] +impl PyMultiPolygon { + #[new] + fn new(polygons: Vec) -> Self { + let polygons = polygons.into_iter().map(PyPolygon::into).collect(); + Self(geo::MultiPolygon::new(polygons)) + } +} + +impl_geo_wrapper!( + GeometryCollection, + PyGeometryCollection, + "GeometryCollection" +); + +#[pymethods] +impl PyGeometryCollection { + #[new] + fn new(geometries: Vec) -> Self { + Self(geo::GeometryCollection::from(geometries)) + } +} + +impl_geo_wrapper!(Rect, PyRect, "Rect"); + +#[pymethods] +impl PyRect { + #[new] + fn new(corner_1: PyPointOrTuple, corner_2: PyPointOrTuple) -> Self { + Self(geo::Rect::new::( + corner_1.into(), + corner_2.into(), + )) + } +} + +impl_geo_wrapper!(Triangle, PyTriangle, "Triangle"); + +#[pymethods] +impl PyTriangle { + #[new] + fn new(a: PyPointOrTuple, b: PyPointOrTuple, c: PyPointOrTuple) -> Self { + let (a, b, c): (geo::Point, geo::Point, geo::Point) = (a.into(), b.into(), c.into()); + + Self(geo::Triangle::new( + geo::coord! {x: a.x(), y: a.y()}, + geo::coord! {x: b.x(), y: b.y()}, + geo::coord! {x: c.x(), y: c.y()}, + )) + } +} diff --git a/snapr-py/src/lib.rs b/snapr-py/src/lib.rs new file mode 100644 index 0000000..efd7989 --- /dev/null +++ b/snapr-py/src/lib.rs @@ -0,0 +1,159 @@ +use std::io::Cursor; + +use ::snapr::{SnaprBuilder, TileFetcher}; +use image::{DynamicImage, ImageFormat, ImageReader}; +use pyo3::{create_exception, exceptions::PyException, prelude::*, types::PyByteArray}; +use utilities::{to_py_error, to_snapr_error}; + +mod geo; +mod style; +mod utilities; + +#[derive(Debug)] +#[pyclass] +struct Snapr { + tile_fetcher: Py, + tile_size: u32, + height: u32, + width: u32, + zoom: Option, +} + +#[pymethods] +impl Snapr { + #[new] + #[pyo3(signature = (tile_fetcher, tile_size=256, height=600, width=800, zoom=None))] + fn new( + tile_fetcher: Py, + tile_size: u32, + height: u32, + width: u32, + zoom: Option, + ) -> Self { + Self { + tile_fetcher, + tile_size, + height, + width, + zoom, + } + } + + #[pyo3(signature = (geometry, styles = Vec::new()))] + fn generate_snapshot_from_geometry<'py>( + &self, + py: Python<'py>, + geometry: geo::PyGeometry, + styles: Vec, + ) -> PyResult> { + self.generate_snapshot_from_geometries(py, vec![geometry], styles) + } + + #[pyo3(signature = (geometries, styles = Vec::new()))] + fn generate_snapshot_from_geometries<'py>( + &self, + py: Python<'py>, + geometries: Vec, + styles: Vec, + ) -> PyResult> { + let tile_fetcher = |coords: &'_ [(i32, i32)], + zoom: u8| + -> Result, ::snapr::Error> { + let mut tiles = Vec::new(); + + let coords_and_tiles: Vec<(i32, i32, Py)> = self + .tile_fetcher + .call1(py, (coords.to_vec(), zoom)) + .and_then(|any| any.extract(py)) + .map_err(to_snapr_error)?; + + for (x, y, tile) in coords_and_tiles { + let cursor = tile + .extract::>(py) + .map(Cursor::new) + .map_err(to_snapr_error)?; + + let image = ImageReader::new(cursor) + .with_guessed_format() + .map_err(to_snapr_error)? + .decode() + .map_err(to_snapr_error)?; + + tiles.push((x, y, image)); + } + + Ok(tiles) + }; + + let builder = SnaprBuilder::new() + .with_tile_fetcher(TileFetcher::Batch(&tile_fetcher)) + .with_tile_size(self.tile_size) + .with_height(self.height) + .with_width(self.width); + + let snapr = match self.zoom { + Some(zoom) => { + let builder = builder.with_zoom(zoom); + builder.build().map_err(to_py_error) + } + + None => builder.build().map_err(to_py_error), + }?; + + let geometries = geometries + .into_iter() + .map(>::into) + .collect(); + + let styles = styles + .into_iter() + .map(>::into) + .collect::>(); + + let snapshot = snapr + .generate_snapshot_from_geometries(geometries, &styles) + .map_err(to_py_error)?; + + // Estimated size of an 800x600 PNG snapshot is `1.44MB` + let mut bytes = Vec::with_capacity(1_440_000); + + snapshot + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .map_err(to_py_error)?; + + Ok(PyByteArray::new_bound(py, &bytes)) + } +} + +create_exception!(snapr, SnaprError, PyException); + +#[pymodule] +fn snapr(py: Python, module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add("SnaprError", py.get_type_bound::())?; + module.add_class::()?; + + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + + Ok(()) +} diff --git a/snapr-py/src/style.rs b/snapr-py/src/style.rs new file mode 100644 index 0000000..e3cde69 --- /dev/null +++ b/snapr-py/src/style.rs @@ -0,0 +1,207 @@ +use pyo3::prelude::*; +use snapr::{ + drawing::{ + geometry::{ + line::LineStyle, + point::{PointStyle, Representation, Shape}, + polygon::PolygonStyle, + }, + style::{ColorOptions, Style}, + svg::{Label, Svg}, + }, + tiny_skia::Color, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +#[pyclass(name = "Color")] +pub struct PyColor { + r: u8, + g: u8, + b: u8, + a: u8, +} + +#[pymethods] +impl PyColor { + #[new] + fn new(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } +} + +#[allow(clippy::from_over_into)] +impl Into for PyColor { + fn into(self) -> Color { + Color::from_rgba8(self.r, self.g, self.b, self.a) + } +} + +#[derive(Clone, Debug, PartialEq)] +#[pyclass(name = "ColorOptions")] +pub struct PyColorOptions(ColorOptions); + +#[pymethods] +impl PyColorOptions { + #[new] + #[pyo3(signature = (foreground = PyColor::new(248, 248, 248, 255), background = PyColor::new(26, 26, 26, 255), anti_alias=true, border=1.0))] + fn new( + foreground: PyColor, + background: PyColor, + anti_alias: bool, + border: Option, + ) -> Self { + Self(ColorOptions { + foreground: foreground.into(), + background: background.into(), + anti_alias, + border, + }) + } +} + +#[derive(Clone, Debug, PartialEq)] +#[pyclass(name = "Style")] +pub enum PyStyle { + #[pyo3(constructor = (_0 = PyPointStyle(PointStyle::default())))] + Point(PyPointStyle), + + #[pyo3(constructor = (_0 = PyLineStyle(LineStyle::default())))] + Line(PyLineStyle), + + #[pyo3(constructor = (_0 = PyPolygonStyle(PolygonStyle::default())))] + Polygon(PyPolygonStyle), +} + +#[allow(clippy::from_over_into)] +impl Into