pax_global_header00006660000000000000000000000064152122713700014512gustar00rootroot0000000000000052 comment=f9310c32a606f99998b885e4780d51b1417ec3c5 adrien-berchet-WKB_WKT_Converter-f9310c3/000077500000000000000000000000001521227137000201155ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/.github/000077500000000000000000000000001521227137000214555ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/.github/workflows/000077500000000000000000000000001521227137000235125ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/.github/workflows/ci.yml000066400000000000000000000101131521227137000246240ustar00rootroot00000000000000name: CI on: push: branches: [ main ] tags: - '*' pull_request: workflow_dispatch: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 jobs: rust: name: Rust (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: components: clippy, rustfmt - name: Rust version run: rustc --version && cargo clippy --version - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Format check run: cargo fmt --all -- --check - name: Clippy run: cargo clippy --all-targets --all-features -- -D warnings - name: Tests run: cargo test --workspace coverage: name: Coverage runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - name: Generate coverage report run: cargo llvm-cov --package wkb_wkt_converter --lcov --output-path lcov.info --fail-under-lines 100 - name: Upload to Codecov uses: codecov/codecov-action@v6 with: files: lcov.info fail_ci_if_error: true env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} python: name: Python wheel (ubuntu) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install maturin run: pip install maturin - name: Build and install Python wheel with test dependencies run: | maturin build --release pip install --force-reinstall "wkb_wkt_converter[test]" --find-links target/wheels/ - name: Run Python tests run: pytest python-pypy: name: PyPy wheel (ubuntu) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Set up PyPy uses: actions/setup-python@v6 with: python-version: "pypy3.11" - name: Install maturin run: python -m pip install maturin - name: Build and install PyPy wheel run: | maturin build --release -i python python -m pip install --force-reinstall target/wheels/*.whl - name: Smoke test PyPy wheel run: | python - <<'PY' import wkb_wkt_converter wkb = wkb_wkt_converter.wkt_to_wkb("POINT (1 2)") assert wkb_wkt_converter.wkb_to_wkt(wkb) == "POINT (1 2)" PY coverage-python: name: Coverage (Python bindings) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable with: components: llvm-tools-preview - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Install cargo-llvm-cov uses: taiki-e/install-action@cargo-llvm-cov - name: Set up Python uses: actions/setup-python@v6 with: python-version: "3.12" - name: Install maturin run: pip install maturin - name: Build and test Python bindings with coverage run: | source <(cargo llvm-cov show-env --export-prefix) maturin build pip install --force-reinstall "wkb_wkt_converter[test]" --find-links target/wheels/ pytest cargo llvm-cov report --package wkb_wkt_converter_py --fail-under-lines 100 adrien-berchet-WKB_WKT_Converter-f9310c3/.github/workflows/fuzz.yml000066400000000000000000000036531521227137000252420ustar00rootroot00000000000000name: Fuzz on: push: branches: [ main ] pull_request: schedule: - cron: '17 3 * * *' workflow_dispatch: env: CARGO_TERM_COLOR: always jobs: fuzz-targets: name: discover fuzz targets runs-on: ubuntu-latest outputs: targets: ${{ steps.targets.outputs.targets }} steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: List fuzz targets id: targets run: echo "targets=$(python3 scripts/list_fuzz_targets.py --json)" >> "$GITHUB_OUTPUT" cargo-fuzz: name: cargo-fuzz (${{ matrix.target }}) needs: fuzz-targets runs-on: ubuntu-latest timeout-minutes: 20 strategy: fail-fast: false matrix: target: ${{ fromJSON(needs.fuzz-targets.outputs.targets) }} steps: - uses: actions/checkout@v6 - name: Install Rust nightly uses: dtolnay/rust-toolchain@nightly - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: workspaces: | . -> target fuzz -> fuzz/target - name: Install cargo-fuzz run: cargo install cargo-fuzz --version 0.13.1 - name: Run bounded fuzz target working-directory: fuzz run: | mkdir -p "corpus/${{ matrix.target }}" if [ "${{ github.event_name }}" = "schedule" ]; then cargo fuzz run "${{ matrix.target }}" "corpus/${{ matrix.target }}" "seeds/${{ matrix.target }}" -- -max_total_time=300 else cargo fuzz run "${{ matrix.target }}" "corpus/${{ matrix.target }}" "seeds/${{ matrix.target }}" -- -runs=1000 fi - name: Upload fuzz artifacts if: failure() uses: actions/upload-artifact@v4 with: name: fuzz-artifacts-${{ matrix.target }} path: fuzz/artifacts/${{ matrix.target }}/ if-no-files-found: ignore retention-days: 7 adrien-berchet-WKB_WKT_Converter-f9310c3/.github/workflows/release.yml000066400000000000000000000225651521227137000256670ustar00rootroot00000000000000name: Release on: push: tags: - "v*" workflow_dispatch: permissions: contents: read env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: validate-release-version: name: Validate release version runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Check tag matches Cargo version if: startsWith(github.ref, 'refs/tags/v') shell: bash run: | tag_version="${GITHUB_REF_NAME#v}" manifest_version="$(cargo pkgid --package wkb_wkt_converter | sed 's/.*#//')" if [[ "${manifest_version}" != "${tag_version}" ]]; then echo "::error title=Release version mismatch::Tag ${GITHUB_REF_NAME} points to wkb_wkt_converter ${manifest_version}. Update [workspace.package] version to ${tag_version} before creating the tag." exit 1 fi build-sdist: name: Build sdist needs: validate-release-version runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Build source distribution uses: PyO3/maturin-action@v1 with: command: sdist maturin-version: v1.11.5 args: --out dist - name: Upload sdist uses: actions/upload-artifact@v4 with: name: sdist path: dist/* if-no-files-found: error build-linux-wheels: name: Build Linux wheel (${{ matrix.python-version }}) needs: validate-release-version runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Build manylinux wheel uses: PyO3/maturin-action@v1 with: maturin-version: v1.11.5 manylinux: "2014" args: >- --release --locked --strip --out dist --compatibility pypi -i python${{ matrix.python-version }} - name: Smoke test wheel shell: bash run: | python -m pip install --force-reinstall dist/*.whl python - <<'PY' import wkb_wkt_converter wkb = wkb_wkt_converter.wkt_to_wkb("POINT (1 2)") assert wkb_wkt_converter.wkb_to_wkt(wkb) == "POINT (1 2)" PY - name: Upload wheel uses: actions/upload-artifact@v4 with: name: wheels-linux-x86_64-py${{ matrix.python-version }} path: dist/* if-no-files-found: error build-x64-native-wheels: name: Build ${{ matrix.platform.name }} wheel (${{ matrix.python-version }}) needs: validate-release-version runs-on: ${{ matrix.platform.os }} strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] platform: - os: macos-15-intel name: macos-x86_64 - os: windows-latest name: windows-x86_64 steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Build wheel uses: PyO3/maturin-action@v1 with: maturin-version: v1.11.5 args: >- --release --locked --strip --out dist --compatibility pypi -i python - name: Smoke test wheel shell: bash run: | python -m pip install --force-reinstall dist/*.whl python - <<'PY' import wkb_wkt_converter wkb = wkb_wkt_converter.wkt_to_wkb("POINT (1 2)") assert wkb_wkt_converter.wkb_to_wkt(wkb) == "POINT (1 2)" PY - name: Upload wheel uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.platform.name }}-py${{ matrix.python-version }} path: dist/* if-no-files-found: error build-macos-arm64-wheels: name: Build macos-arm64 wheel (${{ matrix.python-version }}) needs: validate-release-version runs-on: macos-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Set up Python uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Build wheel uses: PyO3/maturin-action@v1 with: maturin-version: v1.11.5 args: >- --release --locked --strip --out dist --compatibility pypi -i python - name: Smoke test wheel shell: bash run: | python -m pip install --force-reinstall dist/*.whl python - <<'PY' import wkb_wkt_converter wkb = wkb_wkt_converter.wkt_to_wkb("POINT (1 2)") assert wkb_wkt_converter.wkb_to_wkt(wkb) == "POINT (1 2)" PY - name: Upload wheel uses: actions/upload-artifact@v4 with: name: wheels-macos-arm64-py${{ matrix.python-version }} path: dist/* if-no-files-found: error build-pypy-wheels: name: Build PyPy ${{ matrix.platform.name }} wheel (${{ matrix.pypy-version }}) needs: validate-release-version runs-on: ${{ matrix.platform.os }} strategy: fail-fast: false matrix: pypy-version: ["3.11"] platform: - os: ubuntu-latest name: linux-x86_64 - os: macos-15-intel name: macos-x86_64 - os: macos-latest name: macos-arm64 - os: windows-latest name: windows-x86_64 steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Set up PyPy uses: actions/setup-python@v6 with: python-version: pypy${{ matrix.pypy-version }} - name: Build manylinux wheel if: matrix.platform.name == 'linux-x86_64' uses: PyO3/maturin-action@v1 with: maturin-version: v1.11.5 manylinux: "2014" args: >- --release --locked --strip --out dist --compatibility pypi -i pypy${{ matrix.pypy-version }} - name: Build native wheel if: matrix.platform.name != 'linux-x86_64' uses: PyO3/maturin-action@v1 with: maturin-version: v1.11.5 args: >- --release --locked --strip --out dist --compatibility pypi -i python - name: Smoke test wheel shell: bash run: | python -m pip install --force-reinstall dist/*.whl python - <<'PY' import wkb_wkt_converter wkb = wkb_wkt_converter.wkt_to_wkb("POINT (1 2)") assert wkb_wkt_converter.wkb_to_wkt(wkb) == "POINT (1 2)" PY - name: Upload wheel uses: actions/upload-artifact@v4 with: name: wheels-${{ matrix.platform.name }}-pypy${{ matrix.pypy-version }} path: dist/* if-no-files-found: error publish-crates-io: name: Publish to crates.io if: startsWith(github.ref, 'refs/tags/v') needs: - build-sdist - build-linux-wheels - build-x64-native-wheels - build-macos-arm64-wheels - build-pypy-wheels runs-on: ubuntu-latest environment: name: crates-io url: https://crates.io/crates/wkb_wkt_converter permissions: contents: read id-token: write steps: - uses: actions/checkout@v6 - name: Install Rust stable uses: dtolnay/rust-toolchain@stable - name: Cache Cargo uses: Swatinem/rust-cache@v2 - name: Authenticate with crates.io id: auth uses: rust-lang/crates-io-auth-action@v1 - name: Publish crate env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} run: cargo publish --package wkb_wkt_converter --locked publish-pypi: name: Publish to PyPI if: startsWith(github.ref, 'refs/tags/v') needs: - build-sdist - build-linux-wheels - build-x64-native-wheels - build-macos-arm64-wheels - build-pypy-wheels runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/project/wkb-wkt-converter/ permissions: contents: read id-token: write steps: - name: Download distributions uses: actions/download-artifact@v4 with: path: dist merge-multiple: true - name: List distributions run: ls -l dist - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@release/v1 adrien-berchet-WKB_WKT_Converter-f9310c3/.gitignore000066400000000000000000000002121521227137000221000ustar00rootroot00000000000000/target/ /fuzz/artifacts/ /fuzz/corpus/ /fuzz/coverage/ /fuzz/target/ .asv/ __pycache__/ *.pyc .pytest_cache/ lcov.info uv.lock worktrees adrien-berchet-WKB_WKT_Converter-f9310c3/AGENTS.md000066400000000000000000000146761521227137000214360ustar00rootroot00000000000000# AGENTS.md This file provides guidance to AI coding agents when working with code in this repository. ## Commands ```sh # Rust tests, lint, formatting cargo test --workspace # run all Rust tests cargo test --package wkb_wkt_converter -- # run a single test cargo clippy --all-targets --all-features -- -D warnings cargo fmt --all # apply formatting cargo fmt --all -- --check # check only (CI mode) # Fuzzing (requires cargo-fuzz; fuzz crate is isolated under fuzz/) mkdir -p fuzz/corpus/wkb_bytes fuzz/corpus/wkt_text fuzz/corpus/generic_input fuzz/corpus/structured_roundtrip fuzz/corpus/deep_nesting cargo fuzz run --fuzz-dir fuzz wkb_bytes fuzz/corpus/wkb_bytes fuzz/seeds/wkb_bytes cargo fuzz run --fuzz-dir fuzz wkt_text fuzz/corpus/wkt_text fuzz/seeds/wkt_text cargo fuzz run --fuzz-dir fuzz generic_input fuzz/corpus/generic_input fuzz/seeds/generic_input cargo fuzz run --fuzz-dir fuzz structured_roundtrip fuzz/corpus/structured_roundtrip fuzz/seeds/structured_roundtrip cargo fuzz run --fuzz-dir fuzz deep_nesting fuzz/corpus/deep_nesting fuzz/seeds/deep_nesting # Coverage (must reach 100% line coverage — CI enforces this) cargo llvm-cov --package wkb_wkt_converter --fail-under-lines 100 # Python bindings (requires maturin and a Rust toolchain) maturin develop # build and install into the active virtualenv pytest # run Python binding tests # ASV local benchmark history pip install ".[asv]" asv check --python=same asv run --python=same --quick --show-stderr --dry-run asv run HEAD^! --quick --show-stderr --dry-run # Python binding coverage (instruments Rust code via LLVM when called from Python) source <(cargo llvm-cov show-env --export-prefix) maturin build pip install --force-reinstall target/wheels/*.whl pytest cargo llvm-cov report --package wkb_wkt_converter_py --fail-under-lines 100 ``` ## Test coverage **Line coverage must be 100%** for both the `wkb_wkt_converter` crate and the `wkb_wkt_converter_py` Python bindings crate. CI enforces this with `--fail-under-lines 100`. When adding or changing code: - Every new code path must be exercised by at least one test. - Run `cargo llvm-cov --package wkb_wkt_converter --fail-under-lines 100` locally before pushing to confirm core library coverage is maintained. - For Python binding coverage, use the `source <(cargo llvm-cov show-env --export-prefix)` approach documented in the Commands section above — this instruments the Rust extension module while pytest drives execution. - If a line is genuinely unreachable (e.g. a defensive branch that cannot be triggered through the public API), prefer removing it over marking it as excluded — dead code should not exist in this codebase. ## Performance benchmarks When adding a new public function exported by the `wkb_wkt_converter` Python module, update the ASV benchmark suite in `asv_benchmarks/python_api.py` in the same change. Add a converter-only `time_*` benchmark for at least one representative successful call path, using the existing representative geometry set when applicable. This applies to new public Python API functions, not internal helpers, Rust-only APIs, or behavior-only changes to existing functions unless they introduce a materially different performance path. For WKB/hex inputs, prefer fixed or independently generated fixtures rather than calling the converter under test during benchmark setup. Keep importing `wkb_wkt_converter` inside benchmark `setup` methods so ASV discovery does not require the extension module to be installed yet. Use `asv check --python=same` as the quick ASV sanity check when the ASV extra is installed. ## Architecture This is a Rust workspace with two crates and a Python layer on top: ``` wkb_wkt_converter/ core Rust library (zero runtime dependencies) wkb_wkt_converter_py/ PyO3 bindings; thin wrappers that map Rust errors to ValueError tests/ Python tests (pytest) ``` ### Core library (`wkb_wkt_converter/src/`) **`lib.rs`** — public API surface: direct conversion functions (`wkb_to_wkt`, `wkb_to_wkt_split_srid`, `wkt_to_wkb`, `wkt_to_wkb_split_srid`, `wkt_to_hex_wkb`, `hex_wkb_to_wkt`) plus generic input-detecting converters (`to_wkb`, `to_wkt`, `to_hex_wkb`). The `_split_srid` variants return the SRID separately rather than embedding it in the output. **`types.rs`** — `GeomType` (7 OGC types), `Dimension` (XY/XYZ/XYM/XYZM), and the EWKB flag-bit constants (`EWKB_Z`, `EWKB_M`, `EWKB_SRID`). **`error.rs`** — three-variant `Error` enum (`InvalidWkt`, `InvalidWkb`, `UnsupportedGeometryType`). #### WKB → WKT (`wkb_to_wkt/`) - **`reader.rs`** (`WkbReader`): byte-cursor that reads the byte-order marker, then the 4-byte type code. The type code is decoded for both EWKB (high-bit flags) and ISO WKB (type+1000/2000/3000 offsets). Sub-geometries in MULTI* types each carry their own full WKB header. - **`builder.rs`** (`WktBuilder`): string accumulator. `push_f64` renders whole-valued floats as integers (`1` not `1.0`) and uses Rust's default float formatter otherwise. #### WKT → WKB (`wkt_to_wkb/`) - **`tokenizer.rs`** (`Tokenizer`): hand-written cursor over the WKT `&str`. Handles case-insensitive keywords, optional whitespace, dimension tags (Z / M / ZM), `EMPTY`, and the `SRID=N;` prefix. - **`writer.rs`** (`WkbWriter`): byte buffer with a **seekable-patch pattern** — `reserve_u32()` writes a `0x00000000` placeholder and returns its offset; `patch_u32(pos, value)` seeks back to fill it in once the real count is known. This avoids a two-pass scan of the input. All output is little-endian. - **`mod.rs`**: recursive descent parser that calls `parse_geometry` → type-specific `parse_*` functions. `POINT EMPTY` encodes all coordinates as NaN (PostGIS convention). `MULTIPOINT` auto-detects ISO form `((x y))` vs bare form `(x y)`. ### Python bindings (`wkb_wkt_converter_py/src/lib.rs`) Each public Rust function is wrapped in a `#[pyfunction]` that converts `wkb_wkt_converter::Error` into `PyValueError`. The `#![allow(clippy::useless_conversion)]` suppression at the top is a false-positive workaround required by PyO3's macro expansion. ## Key invariants - **Output is always little-endian EWKB** regardless of the endianness of the input WKB. - **SRID is only in the top-level header**, never in sub-geometry headers of MULTI* or GeometryCollection. - Both EWKB (flag-bit dimension encoding) and ISO WKB (type-offset dimension encoding) are accepted as input; only EWKB is produced as output. adrien-berchet-WKB_WKT_Converter-f9310c3/Cargo.lock000066400000000000000000000072001521227137000220210ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "pyo3" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" dependencies = [ "libc", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", ] [[package]] name = "pyo3-build-config" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" dependencies = [ "libc", "pyo3-build-config", ] [[package]] name = "pyo3-macros" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", "syn", ] [[package]] name = "pyo3-macros-backend" version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ "heck", "proc-macro2", "pyo3-build-config", "quote", "syn", ] [[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "syn" version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "target-lexicon" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "wkb_wkt_converter" version = "0.6.3" dependencies = [ "hex", ] [[package]] name = "wkb_wkt_converter_py" version = "0.6.3" dependencies = [ "pyo3", "wkb_wkt_converter", ] adrien-berchet-WKB_WKT_Converter-f9310c3/Cargo.toml000066400000000000000000000004741521227137000220520ustar00rootroot00000000000000[workspace] members = ["wkb_wkt_converter", "wkb_wkt_converter_py"] resolver = "2" [workspace.package] version = "0.6.3" edition = "2021" authors = ["Adrien Berchet"] license = "MIT" homepage = "https://github.com/adrien-berchet/wkb_wkt_converter" repository = "https://github.com/adrien-berchet/wkb_wkt_converter" adrien-berchet-WKB_WKT_Converter-f9310c3/README.md000066400000000000000000000506221521227137000214010ustar00rootroot00000000000000# wkb_wkt_converter A high-performance, zero-dependency Rust library for streaming conversion between **WKT/EWKT** and **WKB/EWKB** geometry formats used in GIS systems (PostGIS, GDAL, etc.). Exposes both a Rust API and Python bindings (via PyO3/maturin). --- ## Features - All 7 OGC geometry types: `Point`, `LineString`, `Polygon`, `MultiPoint`, `MultiLineString`, `MultiPolygon`, `GeometryCollection` - All coordinate dimensions: XY, XYZ, M, ZM - Both **EWKB** (PostGIS flag-bit encoding) and **ISO WKB** (type+1000 offset encoding) as input - **SRID** preservation (EWKT ↔ EWKB); option to split SRID from geometry - Big-endian and little-endian WKB input; little-endian EWKB output for full conversions, with simple header rewrites preserving input byte order - `EMPTY` geometry support - Per-member `EMPTY` support inside `MULTI*` geometries - Hex WKB convenience helpers - Memory-safe handling for malformed input: structurally invalid input returns descriptive errors, while trusted-valid fast paths may pass through malformed geometry bodies as invalid output bytes/text. ### Conversion strategy WKB → WKT is a straightforward streaming read. WKT → WKB uses a **seekable-buffer approach**: count fields (ring count, point count, etc.) are written as `0` placeholders and patched in-place after the coordinates are streamed, avoiding a two-pass scan of the input. --- ## Rust API Add to `Cargo.toml`: ```toml [dependencies] wkb_wkt_converter = { path = "wkb_wkt_converter" } ``` ### Functions ```rust // WKB/EWKB → WKT/EWKT, with SRID output control pub fn wkb_to_wkt(wkb: &[u8], srid: SridMode) -> Result // WKB/EWKB → WKT, SRID returned separately (not in the string) pub fn wkb_to_wkt_split_srid(wkb: &[u8]) -> Result<(String, Option)> // WKT/EWKT → EWKB bytes, with SRID output control pub fn wkt_to_wkb(wkt: &str, srid: SridMode) -> Result> // WKT/EWKT → EWKB bytes, SRID returned separately (not in bytes) pub fn wkt_to_wkb_split_srid(wkt: &str) -> Result<(Vec, Option)> // WKT/EWKT → uppercase hex-encoded EWKB string, with SRID output control pub fn wkt_to_hex_wkb(wkt: &str, srid: SridMode) -> Result // Hex-encoded WKB/EWKB → WKT/EWKT string, with SRID output control pub fn hex_wkb_to_wkt(hex: &str, srid: SridMode) -> Result // Read only the top-level EWKB SRID header, without converting the geometry pub fn wkb_header_srid(wkb: &[u8]) -> Result> pub fn wkb_header_srid_including_unknown(wkb: &[u8]) -> Result> ``` #### Generic converters These functions accept an explicit `Input`: a WKT/EWKT string, a hex-encoded WKB/EWKB string, or raw WKB/EWKB bytes. Text input is detected automatically (a non-empty, even-length string composed entirely of hex characters is treated as hex WKB; anything else, including odd-length all-hex text, is treated as WKT). `Input::Wkb` is always treated as raw WKB/EWKB, not encoded text. ```rust pub enum Input<'a> { Text(&'a str), Wkb(&'a [u8]), } pub fn to_wkb(input: Input<'_>, srid: SridMode) -> Result> pub fn to_wkt(input: Input<'_>, srid: SridMode, normalize_wkt: bool) -> Result pub fn to_hex_wkb(input: Input<'_>, srid: SridMode) -> Result ``` `SridMode` controls SRID handling in the output of direct and generic converters: | Variant | Behaviour | |---|---| | `SridMode::Auto` | Mirror the input — SRID kept if present, absent if not | | `SridMode::Strip` | Always strip the SRID from the output | | `SridMode::Set(n)` | Always embed SRID `n`, overriding whatever the input contains | `wkb_header_srid` returns `None` for headers without an EWKB SRID flag and for raw non-positive SRID values. Use `wkb_header_srid_including_unknown` to distinguish an EWKB SRID flag carrying an unknown raw value: raw `0` returns `Some(0)`, and raw `0xffffffff` returns `Some(-1)` under the signed i32 SRID model. Validation notes: - WKT coordinates must be finite. WKB coordinate payloads are treated as trusted-valid: all-`NaN` Point remains `POINT EMPTY`, while other `NaN` or infinity values may format as invalid WKT instead of raising an error. - Direct WKB-to-WKT entry points still reject structurally invalid WKB such as truncation, unsupported type codes, excessive nesting, and trailing top-level bytes. - For simple Point, LineString, and Polygon EWKB/WKB hex or raw WKB input in either byte order, `to_wkb(input, SridMode::Strip | SridMode::Set(_))` and the equivalent `to_hex_wkb` paths patch only the top-level header. Malformed coordinate bodies or trailing bytes in those simple fast paths can pass through as invalid output. ISO-dimensional, collection, and non-canonical type headers fall back to a full normalising round-trip. - `to_wkb(Input::Text(hex), SridMode::Auto)` returns decoded bytes without WKB structure validation, and `to_hex_wkb(Input::Text(hex), SridMode::Auto)` validates and uppercases the hex text only. `Input::Wkb` under `SridMode::Auto` is copied or hex-encoded without WKB structure validation. - `GeometryCollection` dimension tags are not inherited by child geometries. An XY collection may contain heterogeneous immediate children. A Z, M, or ZM collection requires each immediate child to declare the same dimension. - WKT and WKB parsing reject geometry nesting beyond the implementation depth limit of 128. `to_wkt` accepts a `normalize_wkt: bool` parameter. When `true`, WKT input is normalised (canonical casing, spacing, coordinate formatting) via a round-trip through WKB. When `false`, only the SRID prefix is adjusted — **no validation is performed: malformed WKT is returned without error.** Leading/trailing whitespace is always trimmed regardless of this flag. Hex WKB and raw WKB input are always decoded to normalised WKT regardless of this flag. Odd-length all-hex input is not detected as hex WKB; with `normalize_wkt=false` it follows the same unvalidated WKT fast path. ### Example ```rust use wkb_wkt_converter::{wkt_to_wkb, wkb_to_wkt, wkt_to_wkb_split_srid, hex_wkb_to_wkt}; use wkb_wkt_converter::{to_wkt, to_hex_wkb, Input, SridMode}; // Basic round-trip let wkb = wkt_to_wkb("POINT (1 2)", SridMode::Auto)?; let wkt = wkb_to_wkt(&wkb, SridMode::Auto)?; assert_eq!(wkt, "POINT (1 2)"); // With SRID embedded let wkb = wkt_to_wkb("SRID=4326;POINT Z (1 2 3)", SridMode::Auto)?; let wkt = wkb_to_wkt(&wkb, SridMode::Auto)?; assert_eq!(wkt, "SRID=4326;POINT Z (1 2 3)"); // SRID split from geometry let (wkb, srid) = wkt_to_wkb_split_srid("SRID=4326;LINESTRING (0 0, 1 1)")?; assert_eq!(srid, Some(4326)); // wkb contains a plain (non-EWKB) LineString // All geometry types and dimensions work the same way let wkb = wkt_to_wkb( "MULTIPOLYGON ZM (((0 0 0 1, 1 0 0 1, 1 1 0 1, 0 0 0 1)))", SridMode::Auto, )?; let wkt = wkb_to_wkt(&wkb, SridMode::Auto)?; assert_eq!(wkt, "MULTIPOLYGON ZM (((0 0 0 1, 1 0 0 1, 1 1 0 1, 0 0 0 1)))"); // Generic converters: input format (WKT, hex WKB, or raw WKB) selected explicitly/detected automatically // Normalise WKT (casing, whitespace) — SridMode::Auto mirrors the input SRID let wkt = to_wkt(Input::Text("point(1 2)"), SridMode::Auto, true)?; assert_eq!(wkt, "POINT (1 2)"); // Add or override an SRID regardless of what the input contains let hex = to_hex_wkb(Input::Text("POINT (1 2)"), SridMode::Set(4326))?; // hex is an EWKB string encoding SRID=4326;POINT (1 2) let wkt = hex_wkb_to_wkt(&hex, SridMode::Strip)?; assert_eq!(wkt, "POINT (1 2)"); // Raw WKB bytes can also go through the generic converters let wkt = to_wkt(Input::Wkb(&wkb), SridMode::Auto, false)?; assert_eq!(wkt, "MULTIPOLYGON ZM (((0 0 0 1, 1 0 0 1, 1 1 0 1, 0 0 0 1)))"); // Strip the SRID without re-encoding (fast path) let wkt = to_wkt(Input::Text("SRID=4326;POINT (1 2)"), SridMode::Strip, false)?; assert_eq!(wkt, "POINT (1 2)"); ``` ### Error handling All functions return `Result<_, wkb_wkt_converter::Error>`: ```rust pub enum Error { InvalidWkt(String), InvalidWkb(String), UnsupportedGeometryType(u32), } ``` ### Fuzzing `cargo-fuzz` support for the core crate lives in [fuzz/README.md](fuzz/README.md). It includes five targets covering raw WKB bytes, arbitrary UTF-8 text, generic `Input` dispatch behavior, bounded valid-geometry round-trips, plus a depth-boundary target for nested `GEOMETRYCOLLECTION` inputs. The GitHub Actions fuzz workflow is a smoke/regression check; it complements, but does not replace, longer local or continuous fuzzing campaigns. --- ## Python API ### Build and install Requires Python 3.10 or newer, [maturin](https://github.com/PyO3/maturin), and a Rust toolchain. Wheels include PEP 561 type stubs for static type checkers and IDEs. ```sh pip install maturin maturin develop # install into the current virtualenv (dev mode) maturin build --release # build a wheel ``` ### Functions ```python from wkb_wkt_converter import ( wkb_to_wkt, wkb_to_wkt_split_srid, wkt_to_wkb, wkt_to_wkb_split_srid, wkt_to_hex_wkb, hex_wkb_to_wkt, wkb_header_srid, # generic converters to_wkb, to_wkt, to_hex_wkb, ) ``` | Function | Input | Output | |---|---|---| | `wkb_to_wkt(wkb, srid=None)` | bytes-like WKB/EWKB | `str` | | `wkb_to_wkt_split_srid(wkb)` | bytes-like WKB/EWKB | `(str, int \| None)` | | `wkt_to_wkb(wkt, srid=None)` | `str` | `bytes` | | `wkt_to_wkb_split_srid(wkt)` | `str` | `(bytes, int \| None)` | | `wkt_to_hex_wkb(wkt, srid=None)` | `str` | `str` | | `hex_wkb_to_wkt(hex_wkb, srid=None)` | `str` | `str` | | `wkb_header_srid(source, *, include_unknown=False)` | WKB/EWKB bytes-like or hex string | `int \| None` | WKB arguments in the Python API accept `bytes`, `bytearray`, `memoryview`, and other C-contiguous one-byte buffer objects. They are always treated as raw WKB/EWKB, not as encoded text. For performance, bytes-like WKB inputs may be borrowed directly without copying. Do not mutate a writable buffer while a converter is reading it, including from native code, another thread, or shared memory. Mutating the buffer during conversion is unsupported and may produce invalid or inconsistent results. All functions above raise `ValueError` on invalid geometry input. The bytes-like WKB functions raise `BufferError` when the Python object does not provide a C-contiguous one-byte buffer. (See `to_wkt` below for an exception when `normalize_wkt=False`.) #### Generic converters These three functions accept a WKT/EWKT string, a hex-encoded WKB/EWKB string, or bytes-like WKB/EWKB input. String input is detected automatically: a non-empty, even-length string composed entirely of hex characters is treated as hex WKB; anything else, including odd-length all-hex text, is treated as WKT. Bytes-like input is always treated as raw WKB/EWKB, not encoded text. | Function | Output | |---|---| | `to_wkb(source, srid=None)` | `bytes` | | `to_wkt(source, srid=None, normalize_wkt=False)` | `str` | | `to_hex_wkb(source, srid=None)` | `str` | The `srid` keyword argument on direct and generic converters controls SRID handling in the output: | Value | Behaviour | |---|---| | `None` *(default)* | Mirror the input — SRID kept if present, absent if not | | `False` | Always strip the SRID from the output | | `int` | Always embed this SRID, overriding whatever the input contains | Validation behavior matches the Rust API: WKT coordinates must be finite, while WKB coordinate payloads are treated as trusted-valid. Simple Point, LineString, and Polygon EWKB/WKB hex or bytes-like inputs in either byte order under `srid=False` or an integer SRID are patched at the top-level header without scanning the body, so malformed bodies or trailing bytes can pass through as invalid output. `to_wkb(source, srid=None)` returns decoded/raw bytes without WKB structure validation, and `to_hex_wkb(source, srid=None)` validates and uppercases hex text or hex-encodes bytes-like input without WKB structure validation. `wkb_header_srid` inspects only the top-level EWKB header. By default it returns `None` for headers without an SRID flag and for raw non-positive SRID values. With `include_unknown=True`, a present SRID flag carrying raw `0` returns `0`, and raw `0xffffffff` returns `-1` under the signed i32 SRID model. `to_wkt` accepts a `normalize_wkt` keyword argument (default `False`). When `True`, WKT input is normalised (canonical casing, spacing, coordinate formatting) via a round-trip through WKB. When `False` (the default), only the SRID prefix is adjusted — **no validation is performed: malformed WKT is returned without raising an error.** Leading/trailing whitespace is always stripped regardless of this flag. Hex WKB and bytes-like WKB input are always decoded to normalised WKT regardless of this flag. Odd-length all-hex input is not detected as hex WKB; with `normalize_wkt=False` it follows the same unvalidated WKT fast path. ### Example ```python from wkb_wkt_converter import wkt_to_wkb, wkb_to_wkt, wkt_to_hex_wkb, hex_wkb_to_wkt from wkb_wkt_converter import to_wkt, to_hex_wkb wkb = wkt_to_wkb("POINT (1 2)") wkt = wkb_to_wkt(wkb) assert wkt == "POINT (1 2)" # EWKT with SRID wkb = wkt_to_wkb("SRID=4326;POLYGON ((0 0, 1 0, 1 1, 0 0))") wkt = wkb_to_wkt(wkb) assert wkt == "SRID=4326;POLYGON ((0 0, 1 0, 1 1, 0 0))" # Hex WKB (common PostGIS text format) hex_wkb = wkt_to_hex_wkb("POINT (1 2)") wkt = hex_wkb_to_wkt(hex_wkb) assert wkt == "POINT (1 2)" srid_hex_wkb = wkt_to_hex_wkb("SRID=4326;POINT (1 2)") wkt = hex_wkb_to_wkt(srid_hex_wkb, srid=False) assert wkt == "POINT (1 2)" # Generic converters: input format detected automatically wkt = to_wkt("point(1 2)", normalize_wkt=True) # normalise WKT assert wkt == "POINT (1 2)" wkt = to_wkt(hex_wkb) # hex WKB -> WKT (always normalised) assert wkt == "POINT (1 2)" wkt = to_wkt(wkb) # raw WKB bytes -> WKT assert wkt == "POINT (1 2)" hex_out = to_hex_wkb("POINT (1 2)", srid=4326) # add SRID wkt = to_wkt(hex_out) assert wkt == "SRID=4326;POINT (1 2)" wkt = to_wkt("SRID=4326;POINT (1 2)", srid=False) # strip SRID (fast path) assert wkt == "POINT (1 2)" ``` --- ## Benchmarks Comparison against [shapely](https://github.com/shapely/shapely) 2.x. Regenerate with: ```sh pip install ".[benchmark]" python scripts/update_readme_benchmarks.py # or, using an already-saved JSON: python scripts/update_readme_benchmarks.py --json benchmark_results.json ``` For local performance history across commits, use [airspeed velocity](https://asv.readthedocs.io/): ```sh pip install ".[asv]" asv check --python=same asv run --python=same --quick --show-stderr --dry-run asv run HEAD^! --quick --show-stderr --dry-run asv run main..HEAD --skip-existing-commits --show-stderr asv run ALL --skip-existing-commits --show-stderr asv publish asv preview asv compare main HEAD --split ``` *2026-05-05 — Python 3.12.3 — 12th Gen Intel(R) Core(TM) i7-12700KF* Times are mean latency per call (lower is better). Speedup = shapely mean ÷ wkb_wkt_converter mean. ### Basic conversions #### `wkt_to_wkb` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 140 ns | 5.2 µs | 37.3× | | LineString (5 pts) | 233 ns | 6.2 µs | 26.7× | | Polygon (5 pts) | 247 ns | 6.2 µs | 25.2× | | GeometryCollection | 387 ns | 8.3 µs | 21.5× | | MultiPolygon | 533 ns | 9.9 µs | 18.6× | | LineString (1000 pts) | 23.3 µs | 209.1 µs | 9.0× | | Polygon (1000 pts) | 40.7 µs | 444.0 µs | 10.9× | #### `wkb_to_wkt` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 148 ns | 3.9 µs | 26.3× | | LineString (5 pts) | 240 ns | 4.4 µs | 18.3× | | Polygon (5 pts) | 140 ns | 4.4 µs | 31.2× | | GeometryCollection | 213 ns | 5.1 µs | 24.0× | | MultiPolygon | 667 ns | 6.9 µs | 10.3× | | LineString (1000 pts) | 38.9 µs | 118.2 µs | 3.0× | | Polygon (1000 pts) | 118.3 µs | 140.8 µs | 1.2× | #### `wkt_to_hex_wkb` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 164 ns | 5.2 µs | 31.6× | | LineString (5 pts) | 337 ns | 6.4 µs | 19.0× | | Polygon (5 pts) | 365 ns | 6.4 µs | 17.6× | | GeometryCollection | 494 ns | 8.7 µs | 17.5× | | MultiPolygon | 847 ns | 9.8 µs | 11.6× | | LineString (1000 pts) | 36.8 µs | 230.0 µs | 6.2× | | Polygon (1000 pts) | 54.5 µs | 456.0 µs | 8.4× | #### `hex_wkb_to_wkt` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 188 ns | 4.0 µs | 21.3× | | LineString (5 pts) | 331 ns | 4.6 µs | 13.9× | | Polygon (5 pts) | 219 ns | 4.6 µs | 21.1× | | GeometryCollection | 352 ns | 5.6 µs | 15.8× | | MultiPolygon | 854 ns | 6.8 µs | 8.0× | | LineString (1000 pts) | 47.2 µs | 133.3 µs | 2.8× | | Polygon (1000 pts) | 126.7 µs | 156.0 µs | 1.2× | ### Generic `to_*` converters #### `to_wkb(wkt)` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 146 ns | 5.4 µs | 36.8× | | LineString (5 pts) | 241 ns | 6.7 µs | 27.7× | | Polygon (5 pts) | 273 ns | 6.5 µs | 23.9× | | GeometryCollection | 391 ns | 8.5 µs | 21.6× | | MultiPolygon | 565 ns | 9.6 µs | 17.0× | | LineString (1000 pts) | 24.0 µs | 215.0 µs | 9.0× | | Polygon (1000 pts) | 41.2 µs | 452.8 µs | 11.0× | #### `to_wkb(hex_wkb)` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 79 ns | 4.5 µs | 57.4× | | LineString (5 pts) | 116 ns | 4.8 µs | 41.6× | | Polygon (5 pts) | 117 ns | 4.7 µs | 40.4× | | GeometryCollection | 160 ns | 5.3 µs | 33.2× | | MultiPolygon | 215 ns | 5.9 µs | 27.7× | | LineString (1000 pts) | 7.2 µs | 42.8 µs | 5.9× | | Polygon (1000 pts) | 7.2 µs | 43.3 µs | 6.0× | #### `to_wkt(wkt)` — `normalize_wkt=False` (fast path, no WKB round-trip) | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 62 ns | 4.8 µs | 76.8× | | LineString (5 pts) | 60 ns | 6.1 µs | 101.3× | | Polygon (5 pts) | 61 ns | 6.1 µs | 100.2× | | GeometryCollection | 65 ns | 8.4 µs | 129.9× | | MultiPolygon | 66 ns | 10.6 µs | 160.0× | | LineString (1000 pts) | 521 ns | 306.5 µs | 588.0× | | Polygon (1000 pts) | 2.1 µs | 547.8 µs | 267.2× | #### `to_wkt(wkt)` — `normalize_wkt=True` (full WKT→WKB→WKT round-trip) | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 346 ns | 4.8 µs | 13.8× | | LineString (5 pts) | 550 ns | 6.1 µs | 11.1× | | Polygon (5 pts) | 458 ns | 6.1 µs | 13.3× | | GeometryCollection | 685 ns | 8.4 µs | 12.3× | | MultiPolygon | 1.3 µs | 10.6 µs | 8.1× | | LineString (1000 pts) | 62.9 µs | 306.5 µs | 4.9× | | Polygon (1000 pts) | 162.3 µs | 547.8 µs | 3.4× | #### `to_wkt(hex_wkb)` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 195 ns | 4.1 µs | 21.1× | | LineString (5 pts) | 349 ns | 4.6 µs | 13.2× | | Polygon (5 pts) | 231 ns | 4.7 µs | 20.1× | | GeometryCollection | 359 ns | 5.5 µs | 15.4× | | MultiPolygon | 873 ns | 6.7 µs | 7.7× | | LineString (1000 pts) | 46.8 µs | 132.5 µs | 2.8× | | Polygon (1000 pts) | 126.9 µs | 155.2 µs | 1.2× | #### `to_hex_wkb(wkt)` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 177 ns | 5.4 µs | 30.4× | | LineString (5 pts) | 350 ns | 6.4 µs | 18.3× | | Polygon (5 pts) | 375 ns | 6.8 µs | 18.3× | | GeometryCollection | 524 ns | 8.7 µs | 16.6× | | MultiPolygon | 869 ns | 9.8 µs | 11.3× | | LineString (1000 pts) | 37.3 µs | 228.1 µs | 6.1× | | Polygon (1000 pts) | 54.7 µs | 463.0 µs | 8.5× | #### `to_hex_wkb(hex_wkb)` | Geometry | wkb_wkt_converter | shapely | Speedup | |:---|---:|---:|---:| | Point | 86 ns | 4.5 µs | 52.6× | | LineString (5 pts) | 169 ns | 4.9 µs | 29.1× | | Polygon (5 pts) | 174 ns | 4.9 µs | 28.5× | | GeometryCollection | 238 ns | 5.5 µs | 23.0× | | MultiPolygon | 366 ns | 6.2 µs | 17.0× | | LineString (1000 pts) | 14.8 µs | 56.7 µs | 3.8× | | Polygon (1000 pts) | 15.1 µs | 58.1 µs | 3.8× | --- ## Project layout ``` wkb_wkt_converter/ # core Rust library (zero runtime dependencies) src/ lib.rs # public API error.rs types.rs # GeomType, Dimension wkb_to_wkt/ # WKB reader + WKT builder wkt_to_wkb/ # WKT tokenizer + seekable WKB writer tests/ wkb_to_wkt.rs wkt_to_wkb.rs generic_converters.rs wkb_wkt_converter_py/ # Python bindings (PyO3 / maturin) src/lib.rs pyproject.toml # maturin build config ``` --- ## Running tests ```sh cargo test # run all Rust tests cargo clippy -- -D warnings # lints cargo fmt --check # formatting ``` adrien-berchet-WKB_WKT_Converter-f9310c3/asv.conf.json000066400000000000000000000007001521227137000225220ustar00rootroot00000000000000{ "version": 1, "project": "wkb_wkt_converter", "project_url": "https://github.com/adrien-berchet/wkb_wkt_converter", "repo": ".", "branches": ["main", "HEAD"], "pythons": ["3.11"], "environment_type": "virtualenv", "benchmark_dir": "asv_benchmarks", "env_dir": ".asv/env", "results_dir": ".asv/results", "html_dir": ".asv/html", "build_command": [ "python -m pip wheel --no-deps -w {build_cache_dir} {build_dir}" ] } adrien-berchet-WKB_WKT_Converter-f9310c3/asv_benchmarks/000077500000000000000000000000001521227137000231035ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/asv_benchmarks/__init__.py000066400000000000000000000000711521227137000252120ustar00rootroot00000000000000"""ASV benchmark suite for local performance history.""" adrien-berchet-WKB_WKT_Converter-f9310c3/asv_benchmarks/python_api.py000066400000000000000000000127011521227137000256300ustar00rootroot00000000000000"""Converter-only ASV benchmarks for the Python API.""" import math import struct def _linestring_wkt(n): return "LINESTRING (" + ", ".join(f"{i} {i * 0.5}" for i in range(n)) + ")" def _circle_polygon_wkt(n): coords = [ f"{math.cos(2 * math.pi * i / n)} {math.sin(2 * math.pi * i / n)}" for i in range(n) ] coords.append(coords[0]) return f"POLYGON (({', '.join(coords)}))" def _circle_polygon_coords(n): coords = [ (math.cos(2 * math.pi * i / n), math.sin(2 * math.pi * i / n)) for i in range(n) ] coords.append(coords[0]) return coords def _wkb_header(geom_type): return struct.pack(" str: return "LINESTRING (" + ", ".join(f"{i} {i * 0.5}" for i in range(n)) + ")" def _circle_polygon_wkt(n: int) -> str: coords = [ f"{math.cos(2 * math.pi * i / n)} {math.sin(2 * math.pi * i / n)}" for i in range(n) ] coords.append(coords[0]) return f"POLYGON (({', '.join(coords)}))" GEOMETRIES_WKT = { "point": "POINT (1.5 2.5)", "linestring_5pts": _linestring_wkt(5), "linestring_1000pts": _linestring_wkt(1000), "polygon_5pts": "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "polygon_1000pts": _circle_polygon_wkt(1000), "multipolygon": ( "MULTIPOLYGON (" "((0 0, 1 0, 1 1, 0 1, 0 0), (0.2 0.2, 0.8 0.2, 0.8 0.8, 0.2 0.8, 0.2 0.2)), " "((2 2, 3 2, 3 3, 2 3, 2 2)))" ), "geometry_collection": ( "GEOMETRYCOLLECTION (" "POINT (0 0), " "LINESTRING (0 0, 1 1), " "POLYGON ((0 0, 1 0, 1 1, 0 0)))" ), } # Pre-compute WKB/hex versions used as inputs for wkb→wkt and hex→wkt benchmarks GEOMETRIES_WKB = {name: conv.wkt_to_wkb(wkt) for name, wkt in GEOMETRIES_WKT.items()} GEOMETRIES_HEX_WKB = {name: conv.wkt_to_hex_wkb(wkt) for name, wkt in GEOMETRIES_WKT.items()} _WKT_PARAMS = pytest.mark.parametrize("name", list(GEOMETRIES_WKT), ids=list(GEOMETRIES_WKT)) _WKB_PARAMS = pytest.mark.parametrize("name", list(GEOMETRIES_WKB), ids=list(GEOMETRIES_WKB)) # --------------------------------------------------------------------------- # wkt_to_wkb # --------------------------------------------------------------------------- @_WKT_PARAMS def test_conv_wkt_to_wkb(benchmark, name): benchmark(conv.wkt_to_wkb, GEOMETRIES_WKT[name]) @shapely_required @_WKT_PARAMS def test_shapely_wkt_to_wkb(benchmark, name): wkt = GEOMETRIES_WKT[name] benchmark(lambda: to_wkb(from_wkt(wkt))) # --------------------------------------------------------------------------- # wkb_to_wkt # --------------------------------------------------------------------------- @_WKB_PARAMS def test_conv_wkb_to_wkt(benchmark, name): benchmark(conv.wkb_to_wkt, GEOMETRIES_WKB[name]) @shapely_required @_WKB_PARAMS def test_shapely_wkb_to_wkt(benchmark, name): wkb = GEOMETRIES_WKB[name] benchmark(lambda: to_wkt(from_wkb(wkb))) # --------------------------------------------------------------------------- # wkt_to_hex_wkb # --------------------------------------------------------------------------- @_WKT_PARAMS def test_conv_wkt_to_hex_wkb(benchmark, name): benchmark(conv.wkt_to_hex_wkb, GEOMETRIES_WKT[name]) @shapely_required @_WKT_PARAMS def test_shapely_wkt_to_hex_wkb(benchmark, name): wkt = GEOMETRIES_WKT[name] benchmark(lambda: to_wkb(from_wkt(wkt)).hex().upper()) # --------------------------------------------------------------------------- # hex_wkb_to_wkt # --------------------------------------------------------------------------- @_WKB_PARAMS def test_conv_hex_wkb_to_wkt(benchmark, name): benchmark(conv.hex_wkb_to_wkt, GEOMETRIES_HEX_WKB[name]) @shapely_required @_WKB_PARAMS def test_shapely_hex_wkb_to_wkt(benchmark, name): hex_wkb = GEOMETRIES_HEX_WKB[name] benchmark(lambda: to_wkt(from_wkb(bytes.fromhex(hex_wkb)))) # --------------------------------------------------------------------------- # to_wkb — WKT input path # --------------------------------------------------------------------------- @_WKT_PARAMS def test_conv_to_wkb_from_wkt(benchmark, name): benchmark(conv.to_wkb, GEOMETRIES_WKT[name]) @shapely_required @_WKT_PARAMS def test_shapely_to_wkb_from_wkt(benchmark, name): wkt = GEOMETRIES_WKT[name] benchmark(lambda: to_wkb(from_wkt(wkt))) # --------------------------------------------------------------------------- # to_wkb — hex WKB input path # --------------------------------------------------------------------------- @_WKB_PARAMS def test_conv_to_wkb_from_hex(benchmark, name): benchmark(conv.to_wkb, GEOMETRIES_HEX_WKB[name]) @shapely_required @_WKB_PARAMS def test_shapely_to_wkb_from_hex(benchmark, name): hex_wkb = GEOMETRIES_HEX_WKB[name] benchmark(lambda: to_wkb(from_wkb(bytes.fromhex(hex_wkb)))) # --------------------------------------------------------------------------- # to_wkt — WKT input, normalize_wkt=False (fast path: no WKB round-trip) # Shapely's nearest functional equivalent is WKT parse+serialize; it is shared # with the normalize_wkt=True comparison below. # --------------------------------------------------------------------------- @_WKT_PARAMS def test_conv_to_wkt_from_wkt_no_normalize(benchmark, name): benchmark(conv.to_wkt, GEOMETRIES_WKT[name]) # --------------------------------------------------------------------------- # to_wkt — WKT input, normalize_wkt=True (full WKT→WKB→WKT round-trip) # --------------------------------------------------------------------------- @_WKT_PARAMS def test_conv_to_wkt_from_wkt_normalize(benchmark, name): wkt = GEOMETRIES_WKT[name] benchmark(lambda: conv.to_wkt(wkt, normalize_wkt=True)) @shapely_required @_WKT_PARAMS def test_shapely_to_wkt_from_wkt(benchmark, name): wkt = GEOMETRIES_WKT[name] benchmark(lambda: to_wkt(from_wkt(wkt))) # --------------------------------------------------------------------------- # to_wkt — hex WKB input path (always full conversion) # --------------------------------------------------------------------------- @_WKB_PARAMS def test_conv_to_wkt_from_hex(benchmark, name): benchmark(conv.to_wkt, GEOMETRIES_HEX_WKB[name]) @shapely_required @_WKB_PARAMS def test_shapely_to_wkt_from_hex(benchmark, name): hex_wkb = GEOMETRIES_HEX_WKB[name] benchmark(lambda: to_wkt(from_wkb(bytes.fromhex(hex_wkb)))) # --------------------------------------------------------------------------- # to_hex_wkb — WKT input path # --------------------------------------------------------------------------- @_WKT_PARAMS def test_conv_to_hex_wkb_from_wkt(benchmark, name): benchmark(conv.to_hex_wkb, GEOMETRIES_WKT[name]) @shapely_required @_WKT_PARAMS def test_shapely_to_hex_wkb_from_wkt(benchmark, name): wkt = GEOMETRIES_WKT[name] benchmark(lambda: to_wkb(from_wkt(wkt)).hex().upper()) # --------------------------------------------------------------------------- # to_hex_wkb — hex WKB input path # --------------------------------------------------------------------------- @_WKB_PARAMS def test_conv_to_hex_wkb_from_hex(benchmark, name): benchmark(conv.to_hex_wkb, GEOMETRIES_HEX_WKB[name]) @shapely_required @_WKB_PARAMS def test_shapely_to_hex_wkb_from_hex(benchmark, name): hex_wkb = GEOMETRIES_HEX_WKB[name] benchmark(lambda: to_wkb(from_wkb(bytes.fromhex(hex_wkb))).hex().upper()) adrien-berchet-WKB_WKT_Converter-f9310c3/codecov.yml000066400000000000000000000003671521227137000222700ustar00rootroot00000000000000coverage: status: project: default: target: 95% threshold: 1% patch: default: target: 90% threshold: 1% comment: layout: "reach,diff,flags,files" behavior: default require_changes: true adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/000077500000000000000000000000001521227137000211135ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/.gitignore000066400000000000000000000000121521227137000230740ustar00rootroot00000000000000coverage/ adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/Cargo.lock000066400000000000000000000054431521227137000230260ustar00rootroot00000000000000# This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "cc" version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", "libc", "shlex", ] [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", "wasip2", ] [[package]] name = "jobserver" version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom", "libc", ] [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" dependencies = [ "arbitrary", "cc", ] [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen", ] [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wkb_wkt_converter" version = "0.6.1" [[package]] name = "wkb_wkt_converter-fuzz" version = "0.0.0" dependencies = [ "libfuzzer-sys", "wkb_wkt_converter", ] adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/Cargo.toml000066400000000000000000000014141521227137000230430ustar00rootroot00000000000000[package] name = "wkb_wkt_converter-fuzz" version = "0.0.0" publish = false edition = "2021" [package.metadata] cargo-fuzz = true [workspace] [dependencies] libfuzzer-sys = "0.4" wkb_wkt_converter = { path = "../wkb_wkt_converter" } [[bin]] name = "wkb_bytes" path = "fuzz_targets/wkb_bytes.rs" test = false doc = false bench = false [[bin]] name = "wkt_text" path = "fuzz_targets/wkt_text.rs" test = false doc = false bench = false [[bin]] name = "generic_input" path = "fuzz_targets/generic_input.rs" test = false doc = false bench = false [[bin]] name = "structured_roundtrip" path = "fuzz_targets/structured_roundtrip.rs" test = false doc = false bench = false [[bin]] name = "deep_nesting" path = "fuzz_targets/deep_nesting.rs" test = false doc = false bench = false adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/README.md000066400000000000000000000133561521227137000224020ustar00rootroot00000000000000# Fuzzing This workspace keeps fuzzing isolated in `fuzz/` so normal `cargo test --workspace` and coverage jobs do not pull in libFuzzer dependencies. ## Prerequisites ```sh rustup component add --toolchain nightly llvm-tools-preview cargo install cargo-fuzz ``` ## Run targets From the repository root: ```sh mkdir -p fuzz/corpus/wkb_bytes fuzz/corpus/wkt_text fuzz/corpus/generic_input fuzz/corpus/structured_roundtrip fuzz/corpus/deep_nesting # Raw WKB/EWKB bytes and SRID helper fast paths. cargo fuzz run --fuzz-dir fuzz wkb_bytes fuzz/corpus/wkb_bytes fuzz/seeds/wkb_bytes # Arbitrary UTF-8 text through WKT/EWKT and auto-detected hex-text entry points. cargo fuzz run --fuzz-dir fuzz wkt_text fuzz/corpus/wkt_text fuzz/seeds/wkt_text # Generic Input::Text / Input::Wkb dispatch, SridMode, and normalize_wkt behavior. cargo fuzz run --fuzz-dir fuzz generic_input fuzz/corpus/generic_input fuzz/seeds/generic_input # Bounded valid-geometry generator for parser/encoder round-trips. cargo fuzz run --fuzz-dir fuzz structured_roundtrip fuzz/corpus/structured_roundtrip fuzz/seeds/structured_roundtrip # Near-limit and over-limit nested GeometryCollection inputs for depth guards. cargo fuzz run --fuzz-dir fuzz deep_nesting fuzz/corpus/deep_nesting fuzz/seeds/deep_nesting ``` Useful maintenance commands: ```sh cd fuzz # Minimize a crashing input. cargo fuzz tmin wkb_bytes artifacts/wkb_bytes/crash-... # Shrink the local generated corpus to the smallest useful set. cargo fuzz cmin wkt_text corpus/wkt_text ``` ## Seeds and generated corpus `fuzz/seeds//` contains small, reviewable seed inputs that are committed to git. `fuzz/corpus//` is the mutable libFuzzer corpus; it is ignored by git and can grow during local runs or CI. When a fuzzing run discovers an input that should become part of the stable starting set, minimize it, give it a descriptive name, and copy only that input into `fuzz/seeds//`. Do not commit the generated hash-named files from `fuzz/corpus/`. `fuzz/Cargo.toml` is the source of truth for fuzz target names. Use `scripts/list_fuzz_targets.py` when scripts or automation need the target list. ## Coverage Coverage is based on the current corpus for each target. For useful numbers, run the fuzz targets first so the corpora have a chance to grow, then generate coverage. From the repository root, the simplest way to generate merged coverage is: ```sh scripts/fuzz_coverage.sh ``` This runs coverage for every fuzz target, merges the target profiles, prints the combined report, and writes: - `fuzz/coverage/merged.profdata` - `fuzz/coverage/merged.txt` - `fuzz/coverage/merged.html` To reuse already-generated `coverage.profdata` files without running `cargo fuzz coverage` again: ```sh scripts/fuzz_coverage.sh --skip-generate ``` The sections below show the manual commands that the script wraps. ```sh cd fuzz mapfile -t TARGETS < <(python3 ../scripts/list_fuzz_targets.py) for target in "${TARGETS[@]}"; do mkdir -p "corpus/$target" cargo +nightly fuzz coverage "$target" "corpus/$target" "seeds/$target" done ``` ### Single Target Use the nightly LLVM tools directly. This avoids depending on the `cargo cov` wrapper, which can be sensitive to Cargo/nightly version mismatches. ```sh HOST=$(rustc +nightly -vV | sed -n 's/^host: //p') LLVM_COV="$(rustc +nightly --print sysroot)/lib/rustlib/$HOST/bin/llvm-cov" "$LLVM_COV" report \ "target/$HOST/coverage/$HOST/release/generic_input" \ -instr-profile=coverage/generic_input/coverage.profdata \ ../wkb_wkt_converter/src ``` Generate an HTML report for the same target: ```sh "$LLVM_COV" show \ "target/$HOST/coverage/$HOST/release/generic_input" \ -instr-profile=coverage/generic_input/coverage.profdata \ --format=html \ ../wkb_wkt_converter/src \ > coverage/generic_input/index.html ``` ### Merged Targets Merge all target profiles to see combined fuzz coverage across the core crate. ```sh HOST=$(rustc +nightly -vV | sed -n 's/^host: //p') LLVM_PROFDATA="$(rustc +nightly --print sysroot)/lib/rustlib/$HOST/bin/llvm-profdata" LLVM_COV="$(rustc +nightly --print sysroot)/lib/rustlib/$HOST/bin/llvm-cov" TARGETS=( wkb_bytes wkt_text generic_input structured_roundtrip deep_nesting ) profiles=() for target in "${TARGETS[@]}"; do profiles+=("coverage/$target/coverage.profdata") done "$LLVM_PROFDATA" merge -sparse "${profiles[@]}" -o coverage/merged.profdata main_object="target/$HOST/coverage/$HOST/release/${TARGETS[0]}" extra_objects=() for target in "${TARGETS[@]:1}"; do extra_objects+=("-object" "target/$HOST/coverage/$HOST/release/$target") done "$LLVM_COV" report \ "$main_object" \ "${extra_objects[@]}" \ -instr-profile=coverage/merged.profdata \ ../wkb_wkt_converter/src ``` Generate merged HTML: ```sh "$LLVM_COV" show \ "$main_object" \ "${extra_objects[@]}" \ -instr-profile=coverage/merged.profdata \ --format=html \ ../wkb_wkt_converter/src \ > coverage/merged.html ``` If merged coverage is low, first run the existing targets longer and regenerate coverage. If specific lines remain untouched after the corpora have grown, add targeted seeds or a new fuzz target for that behavior. ## Target notes - Harnesses accept legitimate `Err` results and only assert documented invariants on successful conversions. - Inputs are size-capped inside the harnesses to avoid fuzzer-driven allocation spikes. - `wkb_bytes` and `generic_input` accept seeds with a `HEX:` prefix so binary fixtures can stay reviewable as ASCII text. - `deep_nesting` constructs nested `GEOMETRYCOLLECTION` WKT and WKB up to and just past the parser depth limit. - The GitHub Actions fuzz workflow runs bounded smoke campaigns on pull requests and longer scheduled runs on `main`; run longer local or continuous campaigns for new vulnerability discovery. adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/fuzz_targets/000077500000000000000000000000001521227137000236425ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/fuzz_targets/common.rs000066400000000000000000000241601521227137000255030ustar00rootroot00000000000000#![allow(dead_code)] use wkb_wkt_converter::{ decode_hex, encode_hex, hex_wkb_to_wkt, to_hex_wkb, to_wkb, to_wkt, wkb_header_srid, wkb_set_srid, wkb_set_srid_writer, wkb_strip_srid, wkb_strip_srid_writer, wkb_to_wkt, wkb_to_wkt_split_srid, wkt_to_hex_wkb, wkt_to_wkb, wkt_to_wkb_split_srid, Input, SridMode, WkbWriter, }; pub const MAX_WKB_INPUT: usize = 64 * 1024; pub const MAX_TEXT_INPUT: usize = 64 * 1024; pub fn srid_mode(selector: u8) -> SridMode { match selector % 4 { 0 => SridMode::Auto, 1 => SridMode::Strip, 2 => SridMode::Set(4326), _ => SridMode::Set(-1), } } pub fn exercise_wkb_seed(data: &[u8]) { if let Some(hex) = data.strip_prefix(b"HEX:") { if let Ok(hex) = std::str::from_utf8(hex) { if let Ok(decoded) = decode_hex(hex.trim()) { exercise_wkb(&decoded); } } } exercise_wkb(data); } pub fn exercise_wkb(wkb: &[u8]) { if wkb.len() > MAX_WKB_INPUT { return; } let split = wkb_to_wkt_split_srid(wkb); let header_srid = wkb_header_srid(wkb); if let (Ok((plain_wkt, split_srid)), Ok(header_srid)) = (&split, &header_srid) { assert_eq!(*split_srid, *header_srid); assert_stable_wkt_roundtrip(plain_wkt); } for mode in [ SridMode::Auto, SridMode::Strip, SridMode::Set(4326), SridMode::Set(-1), ] { if let Ok(wkt) = wkb_to_wkt(wkb, mode) { assert_stable_wkt_roundtrip(&wkt); } let _ = to_wkb(Input::Wkb(wkb), mode); if let Ok(wkt) = to_wkt(Input::Wkb(wkb), mode, true) { assert_stable_wkt_roundtrip(&wkt); } assert_hex_shape(to_hex_wkb(Input::Wkb(wkb), mode).as_deref()); } assert_writer_matches(wkb_strip_srid(wkb), wkb_strip_srid_writer(wkb)); for srid in [-1, 0, 1, 4326, i32::MAX] { assert_writer_matches(wkb_set_srid(wkb, srid), wkb_set_srid_writer(wkb, srid)); } } pub fn exercise_wkt(text: &str) { if text.len() > MAX_TEXT_INPUT { return; } if let Ok(wkb) = wkt_to_wkb(text, SridMode::Auto) { let wkt = wkb_to_wkt(&wkb, SridMode::Auto).expect("freshly encoded WKB must parse"); assert_stable_wkt_roundtrip(&wkt); if let Ok(hex) = wkt_to_hex_wkb(text, SridMode::Auto) { assert_eq!(hex, encode_hex(&wkb)); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Auto).expect("freshly encoded hex must parse"), wkt ); } } if let Ok((wkb, _srid)) = wkt_to_wkb_split_srid(text) { assert_eq!( wkb_header_srid(&wkb).expect("freshly encoded split WKB must expose header"), None ); let (plain_wkt, parsed_srid) = wkb_to_wkt_split_srid(&wkb).expect("freshly encoded split WKB must parse"); assert_eq!(parsed_srid, None); assert_stable_wkt_roundtrip(&plain_wkt); } for mode in [ SridMode::Auto, SridMode::Strip, SridMode::Set(4326), SridMode::Set(-1), ] { if let Ok(wkb) = wkt_to_wkb(text, mode) { let wkt = wkb_to_wkt(&wkb, SridMode::Auto).expect("freshly encoded WKB must parse"); assert_stable_wkt_roundtrip(&wkt); } if let Ok(wkt) = hex_wkb_to_wkt(text, mode) { assert_stable_wkt_roundtrip(&wkt); } assert_hex_shape(wkt_to_hex_wkb(text, mode).as_deref()); exercise_generic_text(text, mode, true); exercise_generic_text(text, mode, false); } } pub fn exercise_generic_text(text: &str, mode: SridMode, normalize_wkt: bool) { if text.len() > MAX_TEXT_INPUT { return; } let _ = to_wkb(Input::Text(text), mode); if let Ok(wkt) = to_wkt(Input::Text(text), mode, normalize_wkt) { if normalize_wkt || decode_hex(text.trim()).is_ok() { assert_stable_wkt_roundtrip(&wkt); } else { assert_eq!(wkt, expected_unvalidated_text_to_wkt(text, mode)); } } assert_hex_shape(to_hex_wkb(Input::Text(text), mode).as_deref()); } pub fn exercise_generic_seed(data: &[u8]) { if let Ok(seed) = std::str::from_utf8(data) { if let Some((kind, mode, normalize_wkt, body)) = parse_generic_seed(seed) { match kind { GenericSeedKind::Text => exercise_generic_text(body, mode, normalize_wkt), GenericSeedKind::Wkb => { let body = body.trim(); if let Some(hex) = body.strip_prefix("HEX:") { if let Ok(wkb) = decode_hex(hex.trim()) { exercise_generic_bytes(&wkb, mode, normalize_wkt); } } else { exercise_generic_bytes(body.as_bytes(), mode, normalize_wkt); } } } return; } if looks_like_text_seed(seed) { exercise_generic_text(seed, SridMode::Auto, true); exercise_generic_text(seed, SridMode::Auto, false); } } let Some((&control, rest)) = data.split_first() else { return; }; let mode = srid_mode(control); let normalize_wkt = control & 0b0000_0100 != 0; if control & 0b0000_1000 == 0 { if rest.len() > MAX_TEXT_INPUT { return; } if let Ok(text) = std::str::from_utf8(rest) { exercise_generic_text(text, mode, normalize_wkt); } } else { exercise_generic_bytes(rest, mode, normalize_wkt); } } pub fn exercise_generic_bytes(bytes: &[u8], mode: SridMode, normalize_wkt: bool) { if bytes.len() > MAX_WKB_INPUT { return; } let _ = to_wkb(Input::Wkb(bytes), mode); if let Ok(wkt) = to_wkt(Input::Wkb(bytes), mode, normalize_wkt) { assert_stable_wkt_roundtrip(&wkt); } assert_hex_shape(to_hex_wkb(Input::Wkb(bytes), mode).as_deref()); } #[derive(Clone, Copy)] enum GenericSeedKind { Text, Wkb, } fn parse_generic_seed(seed: &str) -> Option<(GenericSeedKind, SridMode, bool, &str)> { let (header, body) = seed.split_once("\n\n")?; let mut kind = None; let mut mode = SridMode::Auto; let mut normalize_wkt = false; for line in header.lines() { let (key, value) = line.split_once('=')?; match (key.trim(), value.trim()) { ("kind", "text") => kind = Some(GenericSeedKind::Text), ("kind", "wkb") => kind = Some(GenericSeedKind::Wkb), ("srid", value) => mode = parse_seed_srid_mode(value)?, ("normalize", "true") => normalize_wkt = true, ("normalize", "false") => normalize_wkt = false, _ => return None, } } Some((kind?, mode, normalize_wkt, body)) } fn parse_seed_srid_mode(value: &str) -> Option { match value { "auto" => Some(SridMode::Auto), "strip" => Some(SridMode::Strip), _ => value .strip_prefix("set:") .and_then(|srid| srid.parse::().ok()) .map(SridMode::Set), } } fn looks_like_text_seed(seed: &str) -> bool { let trimmed = seed.trim(); if decode_hex(trimmed).is_ok() { return true; } let start = seed.trim_start(); [ "SRID=", "POINT", "LINESTRING", "POLYGON", "MULTIPOINT", "MULTILINESTRING", "MULTIPOLYGON", "GEOMETRYCOLLECTION", ] .into_iter() .any(|keyword| { start .get(..keyword.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(keyword)) }) } fn expected_unvalidated_text_to_wkt(text: &str, mode: SridMode) -> String { let trimmed = text.trim(); match normalize_srid_mode(mode) { SridMode::Auto => strip_unknown_srid_prefix(trimmed).to_owned(), SridMode::Strip => strip_ewkt_prefix(trimmed).to_owned(), SridMode::Set(srid) => format!("SRID={srid};{}", strip_ewkt_prefix(trimmed)), } } fn normalize_srid_mode(mode: SridMode) -> SridMode { match mode { SridMode::Set(srid) if srid <= 0 => SridMode::Strip, other => other, } } fn strip_unknown_srid_prefix(wkt: &str) -> &str { if wkt .get(..5) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("SRID=")) { if let Some(relative_semicolon) = wkt[5..].find(';') { if let Ok(srid) = wkt[5..5 + relative_semicolon].trim().parse::() { if srid <= 0 { return wkt[6 + relative_semicolon..].trim_start(); } } } } wkt } fn strip_ewkt_prefix(wkt: &str) -> &str { if wkt .get(..5) .is_some_and(|prefix| prefix.eq_ignore_ascii_case("SRID=")) { if let Some(relative_semicolon) = wkt[5..].find(';') { return wkt[6 + relative_semicolon..].trim_start(); } } wkt } fn assert_writer_matches( expected: wkb_wkt_converter::Result>, writer_result: wkb_wkt_converter::Result<(usize, WkbWriter<'_>)>, ) { match (expected, writer_result) { (Ok(expected), Ok((len, writer))) => { assert_eq!(len, expected.len()); let mut out = vec![0; len]; writer.write_into(&mut out); assert_eq!(out, expected); } (Err(_), Err(_)) => {} (Ok(_), Err(err)) => panic!("writer rejected input accepted by allocating API: {err:?}"), (Err(err), Ok(_)) => panic!("writer accepted input rejected by allocating API: {err:?}"), } } pub fn assert_stable_wkt_roundtrip(wkt: &str) { if !is_parseable_wkt_output(wkt) { return; } if let Ok(wkb) = wkt_to_wkb(wkt, SridMode::Auto) { let reparsed = wkb_to_wkt(&wkb, SridMode::Auto) .expect("WKT accepted by encoder must produce parseable WKB"); assert_eq!(reparsed, wkt); } } fn is_parseable_wkt_output(wkt: &str) -> bool { !wkt.contains("NaN") && !wkt.contains("inf") } fn assert_hex_shape(hex: Result<&str, &wkb_wkt_converter::Error>) { if let Ok(hex) = hex { assert_eq!(hex.len() % 2, 0); assert!(hex .bytes() .all(|b| b.is_ascii_digit() || matches!(b, b'A'..=b'F'))); } } adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/fuzz_targets/deep_nesting.rs000066400000000000000000000067711521227137000266670ustar00rootroot00000000000000#![no_main] mod common; use libfuzzer_sys::fuzz_target; use wkb_wkt_converter::{wkb_header_srid, wkb_to_wkt, wkb_to_wkt_split_srid, wkt_to_wkb, SridMode}; const DEPTH_LIMIT: usize = 128; const MAX_TARGET_DEPTH: usize = DEPTH_LIMIT + 8; const EWKB_SRID: u32 = 0x2000_0000; fuzz_target!(|data: &[u8]| { let Some(case) = NestingCase::from_seed(data) else { return; }; let wkt = nested_wkt(case.depth, case.with_srid); let wkb = nested_wkb(case.depth, case.with_srid); if case.depth < DEPTH_LIMIT { let encoded = wkt_to_wkb(&wkt, SridMode::Auto).expect("near-limit WKT should encode"); let decoded = wkb_to_wkt(&wkb, SridMode::Auto).expect("near-limit WKB should decode"); common::assert_stable_wkt_roundtrip(&decoded); let (_, encoded_srid) = wkb_to_wkt_split_srid(&encoded).expect("encoded near-limit WKB should split"); let (_, decoded_srid) = wkb_to_wkt_split_srid(&wkb).expect("seed near-limit WKB should split"); let expected_srid = case.with_srid.then_some(4326); assert_eq!(encoded_srid, expected_srid); assert_eq!(decoded_srid, expected_srid); assert_eq!( wkb_header_srid(&wkb).expect("seed WKB header should parse"), expected_srid ); } else { assert!(wkt_to_wkb(&wkt, SridMode::Auto).is_err()); assert!(wkb_to_wkt(&wkb, SridMode::Auto).is_err()); } }); struct NestingCase { depth: usize, with_srid: bool, } impl NestingCase { fn from_seed(data: &[u8]) -> Option { if data.is_empty() { return None; } if let Ok(text) = std::str::from_utf8(data) { if let Some(depth) = parse_depth_seed(text) { return Some(Self { depth: depth.min(MAX_TARGET_DEPTH), with_srid: text.lines().any(|line| line.trim() == "SRID"), }); } } Some(Self { depth: data[0] as usize % (MAX_TARGET_DEPTH + 1), with_srid: data.get(1).is_some_and(|byte| byte & 1 == 1), }) } } fn parse_depth_seed(text: &str) -> Option { text.lines() .find_map(|line| line.trim().strip_prefix("DEPTH:")) .and_then(|depth| depth.trim().parse().ok()) } fn nested_wkt(depth: usize, with_srid: bool) -> String { let mut out = String::new(); if with_srid { out.push_str("SRID=4326;"); } for _ in 0..depth { out.push_str("GEOMETRYCOLLECTION ("); } out.push_str("POINT (1 2)"); for _ in 0..depth { out.push(')'); } out } fn nested_wkb(depth: usize, with_srid: bool) -> Vec { let mut out = Vec::with_capacity(25 + depth * 9); if depth == 0 { write_header(&mut out, 1, with_srid); write_point_body(&mut out); return out; } for collection_index in 0..depth { write_header(&mut out, 7, with_srid && collection_index == 0); out.extend_from_slice(&1u32.to_le_bytes()); } write_header(&mut out, 1, false); write_point_body(&mut out); out } fn write_header(out: &mut Vec, geom_type: u32, with_srid: bool) { out.push(1); let type_word = geom_type | if with_srid { EWKB_SRID } else { 0 }; out.extend_from_slice(&type_word.to_le_bytes()); if with_srid { out.extend_from_slice(&4326i32.to_le_bytes()); } } fn write_point_body(out: &mut Vec) { out.extend_from_slice(&1.0f64.to_le_bytes()); out.extend_from_slice(&2.0f64.to_le_bytes()); } adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/fuzz_targets/generic_input.rs000066400000000000000000000002051521227137000270400ustar00rootroot00000000000000#![no_main] mod common; use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { common::exercise_generic_seed(data); }); adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/fuzz_targets/structured_roundtrip.rs000066400000000000000000000176411521227137000305330ustar00rootroot00000000000000#![no_main] mod common; use libfuzzer_sys::fuzz_target; use wkb_wkt_converter::{ hex_wkb_to_wkt, to_hex_wkb, to_wkb, to_wkt, wkb_header_srid, wkb_to_wkt, wkb_to_wkt_split_srid, wkt_to_hex_wkb, wkt_to_wkb, wkt_to_wkb_split_srid, Input, SridMode, }; fuzz_target!(|data: &[u8]| { if data.is_empty() { return; } let mut cursor = Cursor::new(data); let wkt = cursor.geometry_with_optional_srid(); let wkb = wkt_to_wkb(&wkt, SridMode::Auto).expect("generated WKT must encode"); let canonical = wkb_to_wkt(&wkb, SridMode::Auto).expect("generated WKB must decode"); common::assert_stable_wkt_roundtrip(&canonical); let (plain_wkt, parsed_srid) = wkb_to_wkt_split_srid(&wkb).expect("generated WKB must split"); let (split_wkb, split_srid) = wkt_to_wkb_split_srid(&wkt).expect("generated WKT must split"); assert_eq!(parsed_srid, split_srid); assert_eq!( wkb_header_srid(&wkb).expect("generated WKB header must parse"), split_srid ); assert_eq!( wkb_header_srid(&split_wkb).expect("generated split WKB header must parse"), None ); assert_eq!( wkb_to_wkt(&split_wkb, SridMode::Auto).expect("generated split WKB must decode"), plain_wkt ); let hex = wkt_to_hex_wkb(&wkt, SridMode::Auto).expect("generated WKT must hex encode"); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Auto).expect("generated hex must decode"), canonical ); assert_eq!( to_wkb(Input::Text(&wkt), SridMode::Auto).expect("generic WKT must encode"), wkb ); assert_eq!( to_wkt(Input::Text(&wkt), SridMode::Auto, true).expect("generic WKT must normalize"), canonical ); assert_eq!( to_hex_wkb(Input::Text(&wkt), SridMode::Auto).expect("generic WKT must hex encode"), hex ); }); #[derive(Clone, Copy, PartialEq, Eq)] enum Dim { Xy, Xyz, Xym, Xyzm, } impl Dim { fn coord_size(self) -> usize { match self { Self::Xy => 2, Self::Xyz | Self::Xym => 3, Self::Xyzm => 4, } } fn tag(self) -> &'static str { match self { Self::Xy => "", Self::Xyz => " Z", Self::Xym => " M", Self::Xyzm => " ZM", } } } struct Cursor<'a> { data: &'a [u8], pos: usize, } impl<'a> Cursor<'a> { fn new(data: &'a [u8]) -> Self { Self { data, pos: 0 } } fn geometry_with_optional_srid(&mut self) -> String { let dim = self.dim(); let body = self.geometry(0, dim); match self.byte() % 5 { 0 => format!("SRID={};{body}", self.positive_srid()), 1 => format!("SRID=0;{body}"), _ => body, } } fn geometry(&mut self, depth: usize, dim: Dim) -> String { let geom = if depth >= 2 { self.byte() % 3 } else { self.byte() % 7 }; match geom { 0 => self.point(dim), 1 => self.linestring(dim), 2 => self.polygon(dim), 3 => self.multi_point(dim), 4 => self.multi_linestring(dim), 5 => self.multi_polygon(dim), _ => self.collection(depth, dim), } } fn point(&mut self, dim: Dim) -> String { if self.byte() % 16 == 0 { return format!("POINT{} EMPTY", dim.tag()); } format!("POINT{} ({})", dim.tag(), self.coord_tuple(dim, 0)) } fn linestring(&mut self, dim: Dim) -> String { if self.byte() % 16 == 0 { return format!("LINESTRING{} EMPTY", dim.tag()); } let count = self.count(2, 5); let coords = (0..count) .map(|i| self.coord_tuple(dim, i)) .collect::>() .join(", "); format!("LINESTRING{} ({coords})", dim.tag()) } fn polygon(&mut self, dim: Dim) -> String { if self.byte() % 16 == 0 { return format!("POLYGON{} EMPTY", dim.tag()); } format!("POLYGON{} (({}))", dim.tag(), self.closed_ring(dim)) } fn multi_point(&mut self, dim: Dim) -> String { if self.byte() % 16 == 0 { return format!("MULTIPOINT{} EMPTY", dim.tag()); } let count = self.count(1, 4); let points = (0..count) .map(|i| format!("({})", self.coord_tuple(dim, i))) .collect::>() .join(", "); format!("MULTIPOINT{} ({points})", dim.tag()) } fn multi_linestring(&mut self, dim: Dim) -> String { if self.byte() % 16 == 0 { return format!("MULTILINESTRING{} EMPTY", dim.tag()); } let count = self.count(1, 3); let lines = (0..count) .map(|_| { let point_count = self.count(2, 4); let coords = (0..point_count) .map(|i| self.coord_tuple(dim, i)) .collect::>() .join(", "); format!("({coords})") }) .collect::>() .join(", "); format!("MULTILINESTRING{} ({lines})", dim.tag()) } fn multi_polygon(&mut self, dim: Dim) -> String { if self.byte() % 16 == 0 { return format!("MULTIPOLYGON{} EMPTY", dim.tag()); } let count = self.count(1, 3); let polygons = (0..count) .map(|_| format!("(({}))", self.closed_ring(dim))) .collect::>() .join(", "); format!("MULTIPOLYGON{} ({polygons})", dim.tag()) } fn collection(&mut self, depth: usize, dim: Dim) -> String { if self.byte() % 16 == 0 { return format!("GEOMETRYCOLLECTION{} EMPTY", dim.tag()); } let count = self.count(1, 3); let members = (0..count) .map(|_| { let child_dim = if dim == Dim::Xy { self.dim() } else { dim }; self.geometry(depth + 1, child_dim) }) .collect::>() .join(", "); format!("GEOMETRYCOLLECTION{} ({members})", dim.tag()) } fn closed_ring(&mut self, dim: Dim) -> String { let base = (self.coord(), self.coord()); let width = (self.byte() % 9 + 1) as f64; let height = (self.byte() % 9 + 1) as f64; let mut points = Vec::with_capacity(5); points.push(self.coord_tuple_from_xy(dim, base.0, base.1, 0)); points.push(self.coord_tuple_from_xy(dim, base.0 + width, base.1, 1)); points.push(self.coord_tuple_from_xy(dim, base.0 + width, base.1 + height, 2)); points.push(self.coord_tuple_from_xy(dim, base.0, base.1 + height, 3)); points.push(self.coord_tuple_from_xy(dim, base.0, base.1, 0)); points.join(", ") } fn coord_tuple(&mut self, dim: Dim, index: usize) -> String { let x = self.coord(); let y = self.coord(); self.coord_tuple_from_xy(dim, x, y, index) } fn coord_tuple_from_xy(&mut self, dim: Dim, x: f64, y: f64, index: usize) -> String { let mut coords = vec![x, y]; while coords.len() < dim.coord_size() { coords.push(self.coord() + index as f64); } coords .into_iter() .map(|value| format!("{value:.3}")) .collect::>() .join(" ") } fn dim(&mut self) -> Dim { match self.byte() % 4 { 0 => Dim::Xy, 1 => Dim::Xyz, 2 => Dim::Xym, _ => Dim::Xyzm, } } fn count(&mut self, min: usize, max: usize) -> usize { min + self.byte() as usize % (max - min + 1) } fn positive_srid(&mut self) -> i32 { 1 + i32::from(self.byte()) * 257 } fn coord(&mut self) -> f64 { let raw = i16::from_be_bytes([self.byte(), self.byte()]); f64::from(raw) / 64.0 } fn byte(&mut self) -> u8 { let byte = self.data[self.pos % self.data.len()]; self.pos += 1; byte } } adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/fuzz_targets/wkb_bytes.rs000066400000000000000000000002011521227137000261720ustar00rootroot00000000000000#![no_main] mod common; use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { common::exercise_wkb_seed(data); }); adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/fuzz_targets/wkt_text.rs000066400000000000000000000003731521227137000260640ustar00rootroot00000000000000#![no_main] mod common; use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if data.len() > common::MAX_TEXT_INPUT { return; } if let Ok(text) = std::str::from_utf8(data) { common::exercise_wkt(text); } }); adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/000077500000000000000000000000001521227137000222165ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/deep_nesting/000077500000000000000000000000001521227137000246625ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/deep_nesting/depth_0000066400000000000000000000000101521227137000261170ustar00rootroot00000000000000DEPTH:0 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/deep_nesting/depth_127000066400000000000000000000000121521227137000262730ustar00rootroot00000000000000DEPTH:127 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/deep_nesting/depth_127_srid000066400000000000000000000000171521227137000273210ustar00rootroot00000000000000DEPTH:127 SRID adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/deep_nesting/depth_128000066400000000000000000000000121521227137000262740ustar00rootroot00000000000000DEPTH:128 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/deep_nesting/depth_136_srid000066400000000000000000000000171521227137000273210ustar00rootroot00000000000000DEPTH:136 SRID adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/deep_nesting/depth_1_srid000066400000000000000000000000151521227137000271460ustar00rootroot00000000000000DEPTH:1 SRID adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/generic_input/000077500000000000000000000000001521227137000250515ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/generic_input/plain_hex_text000066400000000000000000000000531521227137000300050ustar00rootroot000000000000000101000000000000000000F03F0000000000000040 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/generic_input/text_auto_normalize000066400000000000000000000000761521227137000310730ustar00rootroot00000000000000kind=text srid=auto normalize=true SRID=4326;POINT Z (1 2 3) adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/generic_input/text_strip_unvalidated000066400000000000000000000001341521227137000315570ustar00rootroot00000000000000kind=text srid=strip normalize=false SRID=4326;NOT_A_WKT_BUT_GENERIC_TEXT_CAN_PASS_THROUGH adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/generic_input/wkb_hex_set_srid000066400000000000000000000001261521227137000303160ustar00rootroot00000000000000kind=wkb srid=set:4326 normalize=true HEX:0101000000000000000000F03F0000000000000040 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/generic_input/wkb_hex_strip_srid000066400000000000000000000001331521227137000306620ustar00rootroot00000000000000kind=wkb srid=strip normalize=true HEX:0101000020E6100000000000000000F03F0000000000000040 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/structured_roundtrip/000077500000000000000000000000001521227137000265305ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/structured_roundtrip/empty_cases000066400000000000000000000000201521227137000307570ustar00rootroot00000000000000empty-case-seed adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/structured_roundtrip/polygon000066400000000000000000000000241521227137000301360ustar00rootroot00000000000000polygon-seed-000000 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/structured_roundtrip/xy_point000066400000000000000000000000161521227137000303210ustar00rootroot00000000000000xy-point-seed adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/structured_roundtrip/xyzm_collection_srid000066400000000000000000000000321521227137000327110ustar00rootroot00000000000000xyzm-collection-srid-seed adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkb_bytes/000077500000000000000000000000001521227137000242075ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkb_bytes/empty_collection000066400000000000000000000000271521227137000275020ustar00rootroot00000000000000HEX:010700000000000000 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkb_bytes/point_be000066400000000000000000000000571521227137000257330ustar00rootroot00000000000000HEX:00000000013FF00000000000004000000000000000 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkb_bytes/point_le000066400000000000000000000000571521227137000257450ustar00rootroot00000000000000HEX:0101000000000000000000F03F0000000000000040 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkb_bytes/point_zm_srid000066400000000000000000000001271521227137000270120ustar00rootroot00000000000000HEX:01010000E0E6100000000000000000F03F000000000000004000000000000008400000000000001040 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkb_bytes/truncated_srid_header000066400000000000000000000000171521227137000304520ustar00rootroot00000000000000HEX:0101000020 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkt_text/000077500000000000000000000000001521227137000240675ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkt_text/empty_collection000066400000000000000000000000311521227137000273550ustar00rootroot00000000000000GEOMETRYCOLLECTION EMPTY adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkt_text/hex_point000066400000000000000000000000531521227137000260050ustar00rootroot000000000000000101000000000000000000F03F0000000000000040 adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkt_text/multipoint_bare000066400000000000000000000000331521227137000272030ustar00rootroot00000000000000MULTIPOINT (0 0, 1 1, 2 2) adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkt_text/point000066400000000000000000000000141521227137000251360ustar00rootroot00000000000000POINT (1 2) adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkt_text/polygon000066400000000000000000000000441521227137000254770ustar00rootroot00000000000000POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0)) adrien-berchet-WKB_WKT_Converter-f9310c3/fuzz/seeds/wkt_text/srid_zm_collection000066400000000000000000000001271521227137000276740ustar00rootroot00000000000000SRID=4326;GEOMETRYCOLLECTION ZM (POINT ZM (1 2 3 4), LINESTRING ZM (0 0 0 0, 1 1 1 1)) adrien-berchet-WKB_WKT_Converter-f9310c3/pyproject.toml000066400000000000000000000027321521227137000230350ustar00rootroot00000000000000[build-system] requires = ["maturin>=1.0,<2.0"] build-backend = "maturin" [project] name = "wkb_wkt_converter" dynamic = ["version"] requires-python = ">=3.10" description = "High-performance streaming converter between WKT and WKB geometry formats" readme = "README.md" license = { text = "MIT" } keywords = ["wkb", "wkt", "ewkb", "ewkt", "gis", "geospatial", "geometry"] classifiers = [ "Programming Language :: Rust", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Scientific/Engineering :: GIS", ] [project.urls] Repository = "https://github.com/adrien-berchet/wkb_wkt_converter" Issues = "https://github.com/adrien-berchet/wkb_wkt_converter/issues" [tool.maturin] features = ["pyo3/extension-module"] module-name = "wkb_wkt_converter" manifest-path = "wkb_wkt_converter_py/Cargo.toml" [project.optional-dependencies] asv = ["asv>=0.6.5", "virtualenv"] benchmark = ["pytest-benchmark", "shapely"] test = ["hypothesis", "pytest", "shapely"] [tool.pytest.ini_options] testpaths = ["tests"] [dependency-groups] dev = [ "ipython>=8.12.3", "pdbpp>=0.12.1", ] adrien-berchet-WKB_WKT_Converter-f9310c3/scripts/000077500000000000000000000000001521227137000216045ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/scripts/fuzz_coverage.sh000077500000000000000000000060721521227137000250210ustar00rootroot00000000000000#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: scripts/fuzz_coverage.sh [--skip-generate] [--no-html] Generate and merge coverage for all cargo-fuzz targets. Options: --skip-generate Reuse existing fuzz/coverage//coverage.profdata files. --no-html Skip writing fuzz/coverage/merged.html. -h, --help Show this help. EOF } generate=true html=true while (($#)); do case "$1" in --skip-generate) generate=false ;; --no-html) html=false ;; -h|--help) usage exit 0 ;; *) echo "unknown option: $1" >&2 usage >&2 exit 2 ;; esac shift done repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fuzz_dir="$repo_root/fuzz" mapfile -t targets < <(python3 "$repo_root/scripts/list_fuzz_targets.py") if ! command -v rustup >/dev/null 2>&1; then echo "rustup is required so the script can locate nightly LLVM tools" >&2 exit 1 fi if $generate && ! cargo +nightly fuzz --help >/dev/null 2>&1; then echo "cargo-fuzz is required. Install it with: cargo install cargo-fuzz" >&2 exit 1 fi host="$(rustc +nightly -vV | sed -n 's/^host: //p')" sysroot="$(rustc +nightly --print sysroot)" llvm_profdata="$sysroot/lib/rustlib/$host/bin/llvm-profdata" llvm_cov="$sysroot/lib/rustlib/$host/bin/llvm-cov" if [[ ! -x "$llvm_profdata" || ! -x "$llvm_cov" ]]; then echo "nightly llvm-tools-preview is required. Install it with:" >&2 echo " rustup component add --toolchain nightly llvm-tools-preview" >&2 exit 1 fi cd "$fuzz_dir" mkdir -p coverage if $generate; then for target in "${targets[@]}"; do echo "==> Generating coverage for $target" mkdir -p "corpus/$target" cargo +nightly fuzz coverage "$target" "corpus/$target" "seeds/$target" done fi profiles=() objects=() for target in "${targets[@]}"; do profile="coverage/$target/coverage.profdata" object="target/$host/coverage/$host/release/$target" if [[ ! -f "$profile" ]]; then echo "missing coverage profile: fuzz/$profile" >&2 echo "run without --skip-generate, or generate that target first" >&2 exit 1 fi if [[ ! -x "$object" ]]; then echo "missing coverage binary: fuzz/$object" >&2 echo "run without --skip-generate, or generate that target first" >&2 exit 1 fi profiles+=("$profile") objects+=("$object") done echo "==> Merging profiles" "$llvm_profdata" merge -sparse "${profiles[@]}" -o coverage/merged.profdata main_object="${objects[0]}" extra_objects=() for object in "${objects[@]:1}"; do extra_objects+=("-object" "$object") done echo "==> Writing text report to fuzz/coverage/merged.txt" "$llvm_cov" report \ "$main_object" \ "${extra_objects[@]}" \ -instr-profile=coverage/merged.profdata \ "$repo_root/wkb_wkt_converter/src" \ | tee coverage/merged.txt if $html; then echo "==> Writing HTML report to fuzz/coverage/merged.html" "$llvm_cov" show \ "$main_object" \ "${extra_objects[@]}" \ -instr-profile=coverage/merged.profdata \ --format=html \ "$repo_root/wkb_wkt_converter/src" \ > coverage/merged.html fi adrien-berchet-WKB_WKT_Converter-f9310c3/scripts/list_fuzz_targets.py000066400000000000000000000035251521227137000257450ustar00rootroot00000000000000#!/usr/bin/env python3 """List cargo-fuzz targets from the fuzz crate metadata.""" from __future__ import annotations import argparse import json import subprocess import sys from pathlib import Path def fuzz_targets(repo_root: Path) -> list[str]: manifest = repo_root / "fuzz" / "Cargo.toml" metadata = subprocess.run( [ "cargo", "metadata", "--manifest-path", str(manifest), "--no-deps", "--format-version", "1", ], check=True, capture_output=True, text=True, ) document = json.loads(metadata.stdout) targets = [] for package in document["packages"]: if package["manifest_path"] != str(manifest): continue for target in package["targets"]: if "bin" in target["kind"]: targets.append(target["name"]) break if not targets: raise RuntimeError(f"no fuzz targets found in {manifest}") return targets def main() -> int: parser = argparse.ArgumentParser( description="List fuzz target names from fuzz/Cargo.toml.", ) parser.add_argument( "--json", action="store_true", help="emit a compact JSON array for GitHub Actions", ) args = parser.parse_args() repo_root = Path(__file__).resolve().parents[1] targets = fuzz_targets(repo_root) if args.json: print(json.dumps(targets, separators=(",", ":"))) else: print("\n".join(targets)) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except subprocess.CalledProcessError as error: print(error.stderr, file=sys.stderr, end="") raise SystemExit(error.returncode) except RuntimeError as error: print(error, file=sys.stderr) raise SystemExit(1) adrien-berchet-WKB_WKT_Converter-f9310c3/scripts/update_readme_benchmarks.py000066400000000000000000000203771521227137000271630ustar00rootroot00000000000000#!/usr/bin/env python3 """ Run the benchmark suite and update the Benchmarks section of README.md. Usage: python scripts/update_readme_benchmarks.py # run benchmarks, then update python scripts/update_readme_benchmarks.py --json PATH # use existing JSON, skip run The script rewrites the block between and in README.md. """ import argparse import json import re import subprocess import sys import tempfile from pathlib import Path ROOT = Path(__file__).parent.parent README = ROOT / "README.md" BENCHMARKS_DIR = ROOT / "benchmarks" MARKER_START = "" MARKER_END = "" # Ordered geometry labels shown in every table row. GEOMETRY_ORDER = [ "point", "linestring_5pts", "polygon_5pts", "geometry_collection", "multipolygon", "linestring_1000pts", "polygon_1000pts", ] GEOMETRY_LABELS = { "point": "Point", "linestring_5pts": "LineString (5 pts)", "polygon_5pts": "Polygon (5 pts)", "geometry_collection": "GeometryCollection", "multipolygon": "MultiPolygon", "linestring_1000pts": "LineString (1000 pts)", "polygon_1000pts": "Polygon (1000 pts)", } # Each entry: (section_heading, conv_op_name, shapely_op_name_or_None) # conv_op_name and shapely_op_name are the part of # test_(conv|shapely)_[] test names. OPERATION_GROUPS = [ ("Basic conversions", None, None), # group header — no table itself (" `wkt_to_wkb`", "wkt_to_wkb", "wkt_to_wkb"), (" `wkb_to_wkt`", "wkb_to_wkt", "wkb_to_wkt"), (" `wkt_to_hex_wkb`", "wkt_to_hex_wkb", "wkt_to_hex_wkb"), (" `hex_wkb_to_wkt`", "hex_wkb_to_wkt", "hex_wkb_to_wkt"), ("Generic `to_*` converters", None, None), (" `to_wkb(wkt)`", "to_wkb_from_wkt", "to_wkb_from_wkt"), (" `to_wkb(hex_wkb)`", "to_wkb_from_hex", "to_wkb_from_hex"), (" `to_wkt(wkt)` — `normalize_wkt=False` (fast path, no WKB round-trip)", "to_wkt_from_wkt_no_normalize", "to_wkt_from_wkt"), (" `to_wkt(wkt)` — `normalize_wkt=True` (full WKT→WKB→WKT round-trip)", "to_wkt_from_wkt_normalize", "to_wkt_from_wkt"), (" `to_wkt(hex_wkb)`", "to_wkt_from_hex", "to_wkt_from_hex"), (" `to_hex_wkb(wkt)`", "to_hex_wkb_from_wkt", "to_hex_wkb_from_wkt"), (" `to_hex_wkb(hex_wkb)`", "to_hex_wkb_from_hex", "to_hex_wkb_from_hex"), ] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def fmt_time(seconds: float) -> str: if seconds < 1e-6: return f"{seconds * 1e9:.0f} ns" if seconds < 1e-3: return f"{seconds * 1e6:.1f} µs" if seconds < 1: return f"{seconds * 1e3:.1f} ms" return f"{seconds:.2f} s" def fmt_speedup(conv_s: float, shapely_s: float) -> str: ratio = shapely_s / conv_s return f"{ratio:.1f}×" def parse_name(name: str): """'test_conv_wkt_to_wkb[point]' → ('conv', 'wkt_to_wkb', 'point')""" m = re.match(r"test_(conv|shapely)_(.+)\[([^\]]+)\]$", name) return m.groups() if m else None # --------------------------------------------------------------------------- # JSON loading # --------------------------------------------------------------------------- def load_results(json_path: str) -> dict: """ Returns nested dict: { op_name: { geometry: { 'conv': mean_s, 'shapely': mean_s } } } """ with open(json_path) as f: data = json.load(f) results: dict = {} for bench in data["benchmarks"]: parsed = parse_name(bench["name"]) if parsed is None: continue impl, op, geom = parsed mean = bench["stats"]["mean"] results.setdefault(op, {}).setdefault(geom, {})[impl] = mean return results, data # --------------------------------------------------------------------------- # Markdown generation # --------------------------------------------------------------------------- def make_table(conv_op: str, shapely_op: str | None, results: dict) -> str: conv_data = results.get(conv_op, {}) shapely_data = results.get(shapely_op, {}) if shapely_op else {} has_shapely = bool(shapely_data) if has_shapely: lines = [ "| Geometry | wkb_wkt_converter | shapely | Speedup |", "|:---|---:|---:|---:|", ] else: lines = [ "| Geometry | wkb_wkt_converter |", "|:---|---:|", ] for geom in GEOMETRY_ORDER: label = GEOMETRY_LABELS.get(geom, geom) conv_s = conv_data.get(geom, {}).get("conv") if conv_s is None: continue shapely_s = shapely_data.get(geom, {}).get("shapely") if has_shapely: if shapely_s is not None: lines.append( f"| {label} | {fmt_time(conv_s)} | {fmt_time(shapely_s)}" f" | {fmt_speedup(conv_s, shapely_s)} |" ) else: lines.append(f"| {label} | {fmt_time(conv_s)} | — | — |") else: lines.append(f"| {label} | {fmt_time(conv_s)} |") return "\n".join(lines) def generate_section(results: dict, json_data: dict) -> str: mi = json_data.get("machine_info", {}) cpu = mi.get("cpu", {}) cpu_brand = cpu.get("brand_raw", mi.get("processor", "unknown CPU")) python_ver = mi.get("python_version", "?") dt = json_data.get("datetime", "")[:10] # YYYY-MM-DD lines = [ f"*{dt} — Python {python_ver} — {cpu_brand}*", "", "Times are mean latency per call (lower is better)." " Speedup = shapely mean ÷ wkb_wkt_converter mean.", "", ] current_group = None for heading, conv_op, shapely_op in OPERATION_GROUPS: if conv_op is None: # group header current_group = heading.strip() lines.append(f"### {current_group}") lines.append("") else: lines.append(f"#### {heading.strip()}") lines.append("") lines.append(make_table(conv_op, shapely_op, results)) lines.append("") return "\n".join(lines).rstrip() # --------------------------------------------------------------------------- # README update # --------------------------------------------------------------------------- def update_readme(section: str) -> None: text = README.read_text(encoding="utf-8") start = text.find(MARKER_START) end = text.find(MARKER_END) if start == -1 or end == -1: sys.exit(f"ERROR: markers not found in {README}") new_text = ( text[: start + len(MARKER_START)] + "\n\n" + section + "\n\n" + text[end:] ) README.write_text(new_text, encoding="utf-8") print(f"Updated {README}") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--json", metavar="PATH", help="path to an existing benchmark JSON file (skips running benchmarks)", ) args = parser.parse_args() if args.json: json_path = args.json else: tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False) tmp.close() json_path = tmp.name cmd = [sys.executable, "-m", "pytest", str(BENCHMARKS_DIR), f"--benchmark-json={json_path}", "-q"] print("Running:", " ".join(cmd)) proc = subprocess.run(cmd) if proc.returncode not in (0, 1): # 1 = tests failed but benchmarks ran sys.exit(f"pytest exited with code {proc.returncode}") results, json_data = load_results(json_path) section = generate_section(results, json_data) update_readme(section) if __name__ == "__main__": main() adrien-berchet-WKB_WKT_Converter-f9310c3/tests/000077500000000000000000000000001521227137000212575ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/tests/test_property_based.py000066400000000000000000000217371521227137000257240ustar00rootroot00000000000000""" Property-based tests comparing wkb_wkt_converter against Shapely. Two properties are verified for all seven OGC geometry types: 1. WKB round-trip — Shapely EWKB → wkb_to_wkt → wkt_to_wkb must reproduce the original bytes exactly, confirming that our decoder and encoder are exact inverses of each other. 2. WKT-to-WKB oracle — wkt_to_wkb(shapely.to_wkt(geom)) must equal shapely.to_wkb(geom), confirming that our WKT parser and WKB encoder produce byte-identical output to Shapely for the same geometry. Requires: pip install ".[test]" """ import shapely import hypothesis.strategies as st from hypothesis import given import wkb_wkt_converter as conv # ── coordinate strategies ───────────────────────────────────────────────────── # # Coordinates are kept below 1e14 so that Hypothesis's shrinking does not # accidentally produce values at the boundary of push_f64's integer shortcut # (abs < 1e15 AND v == trunc(v)), which would format e.g. 1e14 as "100000000000000" # rather than "1e14". Both representations parse to the same float, so the tests # would still pass, but the bound gives cleaner shrunk counter-examples. # .map(lambda x: x + 0.0) normalises IEEE 754 negative zero to positive zero. # push_f64 outputs both -0.0 and +0.0 as "0", so the converter is correct but # byte comparison would fail if Shapely stored a -0.0 that our round-trip promotes. _coord = st.floats( min_value=-1e14, max_value=1e14, allow_nan=False, allow_infinity=False, ).map(lambda x: x + 0.0) _positive = st.floats(min_value=1e-3, max_value=1e6, allow_nan=False, allow_infinity=False) # ── geometry strategies ─────────────────────────────────────────────────────── @st.composite def st_point_2d(draw): return shapely.Point(draw(_coord), draw(_coord)) @st.composite def st_point_z(draw): return shapely.Point(draw(_coord), draw(_coord), draw(_coord)) @st.composite def st_linestring(draw): n = draw(st.integers(min_value=2, max_value=8)) return shapely.LineString([(draw(_coord), draw(_coord)) for _ in range(n)]) @st.composite def st_polygon(draw): # shapely.box always produces a valid, non-degenerate rectangle. x0, y0 = draw(_coord), draw(_coord) return shapely.box(x0, y0, x0 + draw(_positive), y0 + draw(_positive)) @st.composite def st_multipoint(draw): n = draw(st.integers(min_value=1, max_value=6)) return shapely.MultiPoint([(draw(_coord), draw(_coord)) for _ in range(n)]) @st.composite def st_multilinestring(draw): n = draw(st.integers(min_value=1, max_value=3)) return shapely.MultiLineString([draw(st_linestring()) for _ in range(n)]) @st.composite def st_multipolygon(draw): n = draw(st.integers(min_value=1, max_value=3)) x0, y0 = draw(_coord), draw(_coord) w, h = draw(_positive), draw(_positive) # Space boxes by (w + 1.0) so they can never overlap regardless of w. return shapely.MultiPolygon([ shapely.box(x0 + i * (w + 1.0), y0, x0 + i * (w + 1.0) + w, y0 + h) for i in range(n) ]) @st.composite def st_geometry_collection(draw): return shapely.GeometryCollection([draw(st_point_2d()), draw(st_linestring())]) # ── oracle helpers ──────────────────────────────────────────────────────────── def _shapely_ewkb(geom): """Little-endian EWKB bytes from Shapely, no SRID.""" # byte_order=1 forces little-endian output to match our encoder. # Shapely 2.x defaults to flavor="extended" (EWKB), which is what we emit. return shapely.to_wkb(geom, byte_order=1) def _geom_to_precise_wkt(geom): """Convert a Shapely geometry to WKT using Python's str() for each coordinate. Python's str(float) produces the shortest decimal representation that round-trips to the exact same IEEE 754 double. Shapely's shapely.to_wkt() uses GEOS's rounding_precision, which counts decimal *places* (not significant digits) and can therefore lose the last ULP for small values like 0.014. """ has_z = geom.has_z dim = "Z " if has_z else "" def fmt_coord(c): return " ".join(str(v) for v in c) def fmt_ring(ring): return "(" + ", ".join(fmt_coord(c) for c in ring.coords) + ")" gt = geom.geom_type if gt == "Point": return f"POINT {dim}({fmt_coord(geom.coords[0])})" if gt == "LineString": pts = ", ".join(fmt_coord(c) for c in geom.coords) return f"LINESTRING {dim}({pts})" if gt == "Polygon": rings = [geom.exterior] + list(geom.interiors) return f"POLYGON {dim}(" + ", ".join(fmt_ring(r) for r in rings) + ")" if gt == "MultiPoint": pts = ", ".join("(" + fmt_coord(g.coords[0]) + ")" for g in geom.geoms) return f"MULTIPOINT {dim}({pts})" if gt == "MultiLineString": lines = ", ".join( "(" + ", ".join(fmt_coord(c) for c in g.coords) + ")" for g in geom.geoms ) return f"MULTILINESTRING {dim}({lines})" if gt == "MultiPolygon": polys = [ "(" + ", ".join(fmt_ring(r) for r in [p.exterior] + list(p.interiors)) + ")" for p in geom.geoms ] return f"MULTIPOLYGON {dim}(" + ", ".join(polys) + ")" if gt == "GeometryCollection": parts = ", ".join(_geom_to_precise_wkt(g) for g in geom.geoms) return f"GEOMETRYCOLLECTION ({parts})" raise ValueError(f"Unsupported geometry type: {gt}") # pragma: no cover def _assert_wkb_roundtrip(geom): """Shapely EWKB → wkb_to_wkt → wkt_to_wkb must reproduce the original bytes.""" shapely_wkb = _shapely_ewkb(geom) our_wkt = conv.wkb_to_wkt(shapely_wkb) assert bytes(conv.wkt_to_wkb(our_wkt)) == shapely_wkb def _assert_wkt_to_wkb_oracle(geom): """wkt_to_wkb on precisely-formatted WKT must equal Shapely's own WKB. Uses _geom_to_precise_wkt() rather than shapely.to_wkt() to avoid GEOS's decimal-place rounding, which can fail to distinguish adjacent IEEE 754 doubles for small coordinate values. """ wkt = _geom_to_precise_wkt(geom) assert bytes(conv.wkt_to_wkb(wkt)) == _shapely_ewkb(geom) # ── WKB round-trip tests ────────────────────────────────────────────────────── @given(st_point_2d()) def test_roundtrip_point_2d(geom): _assert_wkb_roundtrip(geom) @given(st_point_z()) def test_roundtrip_point_z(geom): _assert_wkb_roundtrip(geom) @given(st_linestring()) def test_roundtrip_linestring(geom): _assert_wkb_roundtrip(geom) @given(st_polygon()) def test_roundtrip_polygon(geom): _assert_wkb_roundtrip(geom) @given(st_multipoint()) def test_roundtrip_multipoint(geom): _assert_wkb_roundtrip(geom) @given(st_multilinestring()) def test_roundtrip_multilinestring(geom): _assert_wkb_roundtrip(geom) @given(st_multipolygon()) def test_roundtrip_multipolygon(geom): _assert_wkb_roundtrip(geom) @given(st_geometry_collection()) def test_roundtrip_geometry_collection(geom): _assert_wkb_roundtrip(geom) # ── WKT → WKB oracle tests ──────────────────────────────────────────────────── @given(st_point_2d()) def test_oracle_point_2d(geom): _assert_wkt_to_wkb_oracle(geom) @given(st_point_z()) def test_oracle_point_z(geom): _assert_wkt_to_wkb_oracle(geom) @given(st_linestring()) def test_oracle_linestring(geom): _assert_wkt_to_wkb_oracle(geom) @given(st_polygon()) def test_oracle_polygon(geom): _assert_wkt_to_wkb_oracle(geom) @given(st_multipoint()) def test_oracle_multipoint(geom): _assert_wkt_to_wkb_oracle(geom) @given(st_multilinestring()) def test_oracle_multilinestring(geom): _assert_wkt_to_wkb_oracle(geom) @given(st_multipolygon()) def test_oracle_multipolygon(geom): _assert_wkt_to_wkb_oracle(geom) # ── SRID round-trip tests ───────────────────────────────────────────────────── @given(st_point_2d(), st.integers(min_value=1, max_value=2**31 - 1)) def test_srid_preserved_in_roundtrip(geom, srid): # Use our own canonical WKT to avoid any float-formatting difference # between Shapely's WKT output and ours. canonical_wkt = conv.wkb_to_wkt(_shapely_ewkb(geom)) ewkt = f"SRID={srid};{canonical_wkt}" assert conv.wkb_to_wkt(conv.wkt_to_wkb(ewkt)) == ewkt @given(st_linestring(), st.integers(min_value=1, max_value=999999)) def test_srid_extracted_by_split(geom, srid): canonical_wkt = conv.wkb_to_wkt(_shapely_ewkb(geom)) ewkt = f"SRID={srid};{canonical_wkt}" _, extracted = conv.wkb_to_wkt_split_srid(conv.wkt_to_wkb(ewkt)) assert extracted == srid adrien-berchet-WKB_WKT_Converter-f9310c3/tests/test_python_bindings.py000066400000000000000000001451141521227137000260740ustar00rootroot00000000000000""" Python binding tests for wkb_wkt_converter. These tests verify the Python layer: correct types, round-trips, SRID handling, and that errors surface as ValueError with a readable message. """ from array import array import pytest import wkb_wkt_converter as m # ── helpers ─────────────────────────────────────────────────────────────────── def roundtrip(wkt: str) -> str: return m.wkb_to_wkt(m.wkt_to_wkb(wkt)) # ── wkt_to_wkb ──────────────────────────────────────────────────────────────── def test_wkt_to_wkb_returns_bytes(): result = m.wkt_to_wkb("POINT (1 2)") assert isinstance(result, bytes) assert len(result) > 0 def test_wkt_to_wkb_little_endian_marker(): # First byte of WKB is the byte-order marker; 0x01 = little-endian result = m.wkt_to_wkb("POINT (1 2)") assert result[0] == 0x01 def test_wkt_to_wkb_invalid_raises_value_error(): with pytest.raises(ValueError, match="invalid WKT"): m.wkt_to_wkb("NOT_A_GEOMETRY (1 2)") def test_wkt_to_wkb_srid_none_is_default(): ewkt = "SRID=4326;POINT (1 2)" assert m.wkt_to_wkb(ewkt) == m.wkt_to_wkb(ewkt, srid=None) def test_wkt_to_wkb_srid_false_strips_srid(): result = m.wkt_to_wkb("SRID=4326;POINT (1 2)", srid=False) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_wkt_to_wkb_srid_false_no_srid_unchanged(): result = m.wkt_to_wkb("POINT (1 2)", srid=False) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_wkt_to_wkb_srid_int_adds_srid(): result = m.wkt_to_wkb("POINT (1 2)", srid=4326) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_wkt_to_wkb_srid_int_overrides_srid(): result = m.wkt_to_wkb("SRID=4326;POINT (1 2)", srid=3857) assert m.wkb_to_wkt(result) == "SRID=3857;POINT (1 2)" def test_wkt_to_wkb_srid_zero_acts_as_strip(): result = m.wkt_to_wkb("SRID=4326;POINT (1 2)", srid=0) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_wkt_to_wkb_srid_minus_one_acts_as_strip(): result = m.wkt_to_wkb("SRID=4326;POINT (1 2)", srid=-1) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_ewkt_with_negative_srid_strips_srid(): result = m.wkt_to_wkb("SRID=-1;POINT (1 2)") assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_wkt_to_wkb_srid_true_raises_value_error(): with pytest.raises(ValueError, match="srid=True is not valid"): m.wkt_to_wkb("POINT (1 2)", srid=True) def test_wkt_to_wkb_srid_invalid_type_raises_value_error(): with pytest.raises(ValueError, match="srid must be None, False, or an integer"): m.wkt_to_wkb("POINT (1 2)", srid=3.14) def test_wkt_to_wkb_srid_out_of_range_raises_value_error(): with pytest.raises(ValueError, match="32-bit"): m.wkt_to_wkb("POINT (1 2)", srid=2**40) def test_wkt_to_wkb_srid_huge_int_raises_range_value_error(): with pytest.raises(ValueError, match="32-bit"): m.wkt_to_wkb("POINT (1 2)", srid=2**80) def test_wkt_to_wkb_srid_index_error_propagates(): class BadIndex: def __index__(self): raise RuntimeError("boom") with pytest.raises(RuntimeError, match="boom"): m.wkt_to_wkb("POINT (1 2)", srid=BadIndex()) def test_wkt_to_wkb_srid_index_attribute_error_propagates(): class BadIndex: def __index__(self): raise AttributeError("boom") with pytest.raises(AttributeError, match="boom"): m.wkt_to_wkb("POINT (1 2)", srid=BadIndex()) def test_wkt_to_wkb_srid_index_lookup_error_propagates(): class BadIndexLookup: def __getattribute__(self, name): if name == "__index__": raise RuntimeError("boom") return super().__getattribute__(name) with pytest.raises(RuntimeError, match="boom"): m.wkt_to_wkb("POINT (1 2)", srid=BadIndexLookup()) def test_wkt_to_wkb_srid_index_returning_non_int_raises_type_error(): class NonIntegerIndex: def __index__(self): return "4326" with pytest.raises(ValueError, match="srid must be None, False, or an integer"): m.wkt_to_wkb("POINT (1 2)", srid=NonIntegerIndex()) @pytest.mark.parametrize("index_value", [False, True]) def test_wkt_to_wkb_srid_index_returning_bool_raises_type_error(index_value): class BoolIndex: def __index__(self): return index_value with pytest.raises(ValueError, match="srid must be None, False, or an integer"): m.wkt_to_wkb("POINT (1 2)", srid=BoolIndex()) def test_wkt_to_wkb_srid_index_returning_huge_int_raises_range_value_error(): class HugeIndex: def __index__(self): return 2**80 with pytest.raises(ValueError, match="32-bit"): m.wkt_to_wkb("POINT (1 2)", srid=HugeIndex()) def test_wkt_to_wkb_srid_index_overflow_calls_index_once(): class CountingHugeIndex: def __init__(self): self.calls = 0 def __index__(self): self.calls += 1 return 2**80 srid = CountingHugeIndex() with pytest.raises(ValueError, match="32-bit"): m.wkt_to_wkb("POINT (1 2)", srid=srid) assert srid.calls == 1 def test_wkt_to_wkb_srid_index_success_calls_index_once(): class CountingIndex: def __init__(self): self.calls = 0 def __index__(self): self.calls += 1 return 4326 srid = CountingIndex() result = m.wkt_to_wkb("POINT (1 2)", srid=srid) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" assert srid.calls == 1 def test_wkt_to_wkb_invalid_raises_value_error_with_srid_control(): with pytest.raises(ValueError, match="invalid WKT"): m.wkt_to_wkb("NOT_A_GEOMETRY (1 2)", srid=False) with pytest.raises(ValueError, match="invalid WKT"): m.wkt_to_wkb("NOT_A_GEOMETRY (1 2)", srid=4326) # ── wkb_to_wkt ──────────────────────────────────────────────────────────────── def test_wkb_to_wkt_returns_str(): wkb = m.wkt_to_wkb("POINT (1 2)") result = m.wkb_to_wkt(wkb) assert isinstance(result, str) @pytest.mark.parametrize("make_input", [ pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), pytest.param(lambda wkb: memoryview(bytearray(wkb)), id="bytearray-memoryview"), pytest.param(lambda wkb: memoryview(b"\x00" + wkb)[1:], id="sliced-memoryview"), pytest.param(lambda wkb: array("B", wkb), id="unsigned-byte-array"), pytest.param( lambda wkb: array("b", (byte if byte < 128 else byte - 256 for byte in wkb)), id="signed-byte-array", ), ]) def test_wkb_to_wkt_accepts_bytes_like(make_input): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.wkb_to_wkt(make_input(wkb)) == "POINT (1 2)" @pytest.mark.parametrize("make_input", [ pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), ]) def test_wkb_to_wkt_invalid_bytes_like_raises_value_error(make_input): with pytest.raises(ValueError, match="invalid WKB"): m.wkb_to_wkt(make_input(b"\x99")) @pytest.mark.parametrize("make_input", [ pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), ]) def test_wkb_to_wkt_empty_bytes_like_raises_value_error(make_input): with pytest.raises(ValueError, match="invalid WKB"): m.wkb_to_wkt(make_input(b"")) def test_wkb_to_wkt_rejects_non_buffer_input(): with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.wkb_to_wkt(123) def test_wkb_to_wkt_preserves_buffer_acquisition_errors(): view = memoryview(bytearray(b"\x99")) view.release() with pytest.raises(ValueError, match="released memoryview"): m.wkb_to_wkt(view) def test_wkb_to_wkt_rejects_non_contiguous_memoryview(): wkb = m.wkt_to_wkb("POINT (1 2)") with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.wkb_to_wkt(memoryview(wkb)[::2]) def test_wkb_to_wkt_rejects_non_byte_buffer(): with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.wkb_to_wkt(array("H", [1, 2, 3])) def test_wkb_to_wkt_invalid_raises_value_error(): with pytest.raises(ValueError, match="invalid WKB"): m.wkb_to_wkt(b"\x99") def test_wkb_to_wkt_srid_none_is_default(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(wkb) == m.wkb_to_wkt(wkb, srid=None) def test_wkb_to_wkt_srid_none_mirrors_input_with_srid(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(wkb, srid=None) == "SRID=4326;POINT (1 2)" def test_wkb_to_wkt_srid_false_strips_srid(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(wkb, srid=False) == "POINT (1 2)" def test_wkb_to_wkt_srid_false_no_srid_unchanged(): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.wkb_to_wkt(wkb, srid=False) == "POINT (1 2)" def test_wkb_to_wkt_srid_int_adds_srid(): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.wkb_to_wkt(wkb, srid=4326) == "SRID=4326;POINT (1 2)" def test_wkb_to_wkt_srid_int_overrides_srid(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(wkb, srid=3857) == "SRID=3857;POINT (1 2)" def test_wkb_to_wkt_srid_zero_acts_as_strip(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(wkb, srid=0) == "POINT (1 2)" def test_wkb_to_wkt_srid_negative_one_acts_as_strip(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(wkb, srid=-1) == "POINT (1 2)" def test_wkb_to_wkt_srid_true_raises_value_error(): wkb = m.wkt_to_wkb("POINT (1 2)") with pytest.raises(ValueError, match="srid=True is not valid"): m.wkb_to_wkt(wkb, srid=True) def test_wkb_to_wkt_srid_invalid_type_raises_value_error(): wkb = m.wkt_to_wkb("POINT (1 2)") with pytest.raises(ValueError, match="srid must be None, False, or an integer"): m.wkb_to_wkt(wkb, srid=3.14) def test_wkb_to_wkt_invalid_raises_value_error_with_srid_control(): with pytest.raises(ValueError, match="invalid WKB"): m.wkb_to_wkt(b"\x99", srid=False) with pytest.raises(ValueError, match="invalid WKB"): m.wkb_to_wkt(b"\x99", srid=4326) # ── round-trips ─────────────────────────────────────────────────────────────── @pytest.mark.parametrize("wkt", [ "POINT (1 2)", "POINT Z (1 2 3)", "POINT M (1 2 3)", "POINT ZM (1 2 3 4)", "POINT EMPTY", "LINESTRING (0 0, 1 1, 2 2)", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "MULTIPOINT ((0 0), (1 1))", "MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))", "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))", "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))", "GEOMETRYCOLLECTION EMPTY", ]) def test_roundtrip(wkt): assert roundtrip(wkt) == wkt # ── SRID handling ───────────────────────────────────────────────────────────── def test_wkt_to_wkb_embeds_srid(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(wkb) == "SRID=4326;POINT (1 2)" def test_wkt_to_wkb_split_srid_returns_tuple(): wkb, srid = m.wkt_to_wkb_split_srid("SRID=4326;POINT (1 2)") assert isinstance(wkb, bytes) assert srid == 4326 # SRID must not be embedded in the bytes assert m.wkb_to_wkt(wkb) == "POINT (1 2)" def test_wkt_to_wkb_split_srid_no_srid(): wkb, srid = m.wkt_to_wkb_split_srid("POINT (1 2)") assert srid is None assert m.wkb_to_wkt(wkb) == "POINT (1 2)" def test_wkb_to_wkt_split_srid_returns_tuple(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") wkt, srid = m.wkb_to_wkt_split_srid(wkb) assert wkt == "POINT (1 2)" assert srid == 4326 @pytest.mark.parametrize("make_input", [ pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), pytest.param(lambda wkb: memoryview(bytearray(wkb)), id="bytearray-memoryview"), pytest.param(lambda wkb: memoryview(b"\x00" + wkb)[1:], id="sliced-memoryview"), pytest.param(lambda wkb: array("B", wkb), id="unsigned-byte-array"), ]) def test_wkb_to_wkt_split_srid_accepts_bytes_like(make_input): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") wkt, srid = m.wkb_to_wkt_split_srid(make_input(wkb)) assert wkt == "POINT (1 2)" assert srid == 4326 def test_wkb_to_wkt_split_srid_rejects_non_buffer_input(): with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.wkb_to_wkt_split_srid(123) def test_wkb_to_wkt_split_srid_no_srid(): wkb = m.wkt_to_wkb("POINT (1 2)") wkt, srid = m.wkb_to_wkt_split_srid(wkb) assert wkt == "POINT (1 2)" assert srid is None # ── whitespace and case tolerance ──────────────────────────────────────────── @pytest.mark.parametrize("wkt,expected", [ # no space before opening parenthesis ("POINT(1 2)", "POINT (1 2)"), ("LINESTRING(0 0, 1 1)", "LINESTRING (0 0, 1 1)"), ("POLYGON((0 0, 1 0, 1 1, 0 0))", "POLYGON ((0 0, 1 0, 1 1, 0 0))"), ("MULTIPOINT((0 0),(1 1))", "MULTIPOINT ((0 0), (1 1))"), ("GEOMETRYCOLLECTION(POINT(1 2))", "GEOMETRYCOLLECTION (POINT (1 2))"), # lowercase and mixed-case keywords ("point (1 2)", "POINT (1 2)"), ("Point (1 2)", "POINT (1 2)"), ("linestring (0 0, 1 1)", "LINESTRING (0 0, 1 1)"), ("MultiPolygon (((0 0, 1 0, 1 1, 0 0)))","MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"), # tabs and newlines as whitespace ("POINT\t(1\t2)", "POINT (1 2)"), ("LINESTRING\n(\n0 0,\n1 1\n)", "LINESTRING (0 0, 1 1)"), ]) def test_wkt_input_variants(wkt, expected): assert roundtrip(wkt) == expected # ── hex helpers ─────────────────────────────────────────────────────────────── def test_wkt_to_hex_wkb_returns_uppercase_str(): result = m.wkt_to_hex_wkb("POINT (1 2)") assert isinstance(result, str) assert result == result.upper() def test_wkt_to_hex_wkb_srid_none_is_default(): ewkt = "SRID=4326;POINT (1 2)" assert m.wkt_to_hex_wkb(ewkt) == m.wkt_to_hex_wkb(ewkt, srid=None) def test_wkt_to_hex_wkb_srid_false_strips_srid(): result = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)", srid=False) assert m.hex_wkb_to_wkt(result) == "POINT (1 2)" def test_wkt_to_hex_wkb_srid_false_no_srid_unchanged(): result = m.wkt_to_hex_wkb("POINT (1 2)", srid=False) assert m.hex_wkb_to_wkt(result) == "POINT (1 2)" def test_wkt_to_hex_wkb_srid_int_adds_srid(): result = m.wkt_to_hex_wkb("POINT (1 2)", srid=4326) assert m.hex_wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_wkt_to_hex_wkb_srid_int_overrides_srid(): result = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)", srid=3857) assert m.hex_wkb_to_wkt(result) == "SRID=3857;POINT (1 2)" def test_wkt_to_hex_wkb_srid_zero_acts_as_strip(): result = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)", srid=0) assert m.hex_wkb_to_wkt(result) == "POINT (1 2)" def test_wkt_to_hex_wkb_srid_true_raises_value_error(): with pytest.raises(ValueError, match="srid=True is not valid"): m.wkt_to_hex_wkb("POINT (1 2)", srid=True) def test_wkt_to_hex_wkb_srid_invalid_type_raises_value_error(): with pytest.raises(ValueError, match="srid must be None, False, or an integer"): m.wkt_to_hex_wkb("POINT (1 2)", srid=3.14) def test_wkt_to_hex_wkb_invalid_raises_value_error_with_srid_control(): with pytest.raises(ValueError, match="invalid WKT"): m.wkt_to_hex_wkb("NOT_A_GEOMETRY (1 2)", srid=False) with pytest.raises(ValueError, match="invalid WKT"): m.wkt_to_hex_wkb("NOT_A_GEOMETRY (1 2)", srid=4326) def test_hex_wkb_to_wkt_roundtrip(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb) == "POINT (1 2)" def test_hex_wkb_to_wkt_srid_none_is_default(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb) == m.hex_wkb_to_wkt(hex_wkb, srid=None) def test_hex_wkb_to_wkt_srid_none_mirrors_input_with_srid(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb, srid=None) == "SRID=4326;POINT (1 2)" def test_hex_wkb_to_wkt_srid_false_strips_srid(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb, srid=False) == "POINT (1 2)" def test_hex_wkb_to_wkt_srid_false_no_srid_unchanged(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb, srid=False) == "POINT (1 2)" def test_hex_wkb_to_wkt_srid_int_adds_srid(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb, srid=4326) == "SRID=4326;POINT (1 2)" def test_hex_wkb_to_wkt_srid_int_overrides_srid(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb, srid=3857) == "SRID=3857;POINT (1 2)" def test_hex_wkb_to_wkt_srid_zero_acts_as_strip(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb, srid=0) == "POINT (1 2)" def test_hex_wkb_to_wkt_srid_true_raises_value_error(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") with pytest.raises(ValueError, match="srid=True is not valid"): m.hex_wkb_to_wkt(hex_wkb, srid=True) def test_hex_wkb_to_wkt_srid_invalid_type_raises_value_error(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") with pytest.raises(ValueError, match="srid must be None, False, or an integer"): m.hex_wkb_to_wkt(hex_wkb, srid=3.14) def test_hex_wkb_to_wkt_invalid_raises_value_error(): with pytest.raises(ValueError, match="invalid WKB"): m.hex_wkb_to_wkt("ZZ") def test_hex_wkb_to_wkt_accepts_hex_wkb_keyword(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.hex_wkb_to_wkt(hex_wkb=hex_wkb) == "POINT (1 2)" def test_hex_wkb_to_wkt_invalid_raises_value_error_with_srid_control(): with pytest.raises(ValueError, match="invalid WKB"): m.hex_wkb_to_wkt("ZZ", srid=False) with pytest.raises(ValueError, match="invalid WKB"): m.hex_wkb_to_wkt("ZZ", srid=4326) def test_hex_wkb_to_wkt_trailing_bytes_raise_with_srid_control(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") + "DEADBEEF" with pytest.raises(ValueError, match="invalid WKB"): m.hex_wkb_to_wkt(hex_wkb, srid=False) with pytest.raises(ValueError, match="invalid WKB"): m.hex_wkb_to_wkt(hex_wkb, srid=4326) @pytest.mark.parametrize( "old_name", ["text_to_wkb", "text_to_wkt", "text_to_hex_wkb"], ) def test_legacy_text_to_names_are_not_exported(old_name): assert not hasattr(m, old_name) # ── to_wkb ─────────────────────────────────────────────────────────────── def test_to_wkb_from_wkt_returns_bytes(): result = m.to_wkb("POINT (1 2)") assert isinstance(result, bytes) assert result == m.wkt_to_wkb("POINT (1 2)") def test_to_wkb_from_hex_wkb_returns_bytes(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") result = m.to_wkb(hex_wkb) assert isinstance(result, bytes) assert result == m.wkt_to_wkb("POINT (1 2)") @pytest.mark.parametrize("make_input", [ pytest.param(bytes, id="bytes"), pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), pytest.param(lambda wkb: memoryview(b"\x00" + wkb)[1:], id="sliced-memoryview"), pytest.param(lambda wkb: array("B", wkb), id="unsigned-byte-array"), ]) def test_to_wkb_from_bytes_like_wkb_returns_bytes(make_input): wkb = m.wkt_to_wkb("POINT (1 2)") result = m.to_wkb(make_input(wkb)) assert isinstance(result, bytes) assert result == wkb def test_to_wkb_from_bytes_like_wkb_preserves_srid(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") result = m.to_wkb(bytearray(wkb)) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_wkb_preserves_srid_from_wkt(): result = m.to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_wkb_preserves_srid_from_hex_wkb(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") result = m.to_wkb(hex_wkb) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_wkb_invalid_raises_value_error(): with pytest.raises(ValueError): m.to_wkb("NOT_A_GEOMETRY (1 2)") def test_to_wkb_empty_string_raises(): with pytest.raises(ValueError): m.to_wkb("") def test_to_wkb_odd_length_hex_raises(): with pytest.raises(ValueError): m.to_wkb("ABC") def test_to_wkb_srid_zero_acts_as_strip(): result = m.to_wkb("SRID=4326;POINT (1 2)", srid=0) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_to_wkb_srid_none_is_default(): ewkt = "SRID=4326;POINT (1 2)" assert m.to_wkb(ewkt) == m.to_wkb(ewkt, srid=None) def test_to_wkb_srid_none_mirrors_input_with_srid(): result = m.to_wkb("SRID=4326;POINT (1 2)", srid=None) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_wkb_srid_none_mirrors_input_without_srid(): result = m.to_wkb("POINT (1 2)", srid=None) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_to_wkb_srid_false_strips_srid_from_wkt(): result = m.to_wkb("SRID=4326;POINT (1 2)", srid=False) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_to_wkb_srid_false_strips_srid_from_hex_wkb(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") result = m.to_wkb(hex_wkb, srid=False) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_to_wkb_srid_false_strips_srid_from_bytes_like_wkb(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") result = m.to_wkb(memoryview(wkb), srid=False) assert m.wkb_to_wkt(result) == "POINT (1 2)" def test_to_wkb_srid_false_no_srid_unchanged(): assert m.wkb_to_wkt(m.to_wkb("POINT (1 2)", srid=False)) == "POINT (1 2)" def test_to_wkb_srid_int_adds_srid_to_plain_wkt(): result = m.to_wkb("POINT (1 2)", srid=4326) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_wkb_srid_int_overrides_srid_in_ewkt(): result = m.to_wkb("SRID=4326;POINT (1 2)", srid=3857) assert m.wkb_to_wkt(result) == "SRID=3857;POINT (1 2)" def test_to_wkb_srid_int_adds_srid_to_plain_hex_wkb(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") result = m.to_wkb(hex_wkb, srid=4326) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_wkb_srid_int_adds_srid_to_bytes_like_wkb(): wkb = m.wkt_to_wkb("POINT (1 2)") result = m.to_wkb(bytearray(wkb), srid=4326) assert m.wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_wkb_srid_int_overrides_srid_in_hex_ewkb(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") result = m.to_wkb(hex_wkb, srid=3857) assert m.wkb_to_wkt(result) == "SRID=3857;POINT (1 2)" def test_to_wkb_srid_none_does_not_validate_bytes_like_wkb(): assert m.to_wkb(b"\x99") == b"\x99" def test_to_wkb_bytes_like_ascii_hex_is_raw_wkb_not_hex_text(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.to_wkb(hex_wkb.encode("ascii")) == hex_wkb.encode("ascii") def test_to_wkb_accepts_source_keyword(): assert m.to_wkb(source="POINT (1 2)") == m.wkt_to_wkb("POINT (1 2)") def test_to_wkb_rejects_non_buffer_non_str_input(): with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.to_wkb(123) def test_to_wkb_rejects_non_byte_buffer(): with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.to_wkb(array("H", [1, 2, 3])) def test_to_wkb_srid_true_raises_value_error(): with pytest.raises(ValueError, match="srid=True is not valid"): m.to_wkb("POINT (1 2)", srid=True) def test_to_wkb_srid_invalid_type_raises_value_error(): with pytest.raises(ValueError, match="srid must be None, False, or an integer"): m.to_wkb("POINT (1 2)", srid=3.14) # ── to_wkt ─────────────────────────────────────────────────────────────── def test_to_wkt_from_wkt_returns_str(): result = m.to_wkt("POINT (1 2)") assert isinstance(result, str) assert result == "POINT (1 2)" def test_to_wkt_from_hex_wkb_returns_str(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") result = m.to_wkt(hex_wkb) assert isinstance(result, str) assert result == "POINT (1 2)" @pytest.mark.parametrize("make_input", [ pytest.param(bytes, id="bytes"), pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), pytest.param(lambda wkb: memoryview(b"\x00" + wkb)[1:], id="sliced-memoryview"), pytest.param(lambda wkb: array("B", wkb), id="unsigned-byte-array"), ]) def test_to_wkt_from_bytes_like_wkb_returns_str(make_input): wkb = m.wkt_to_wkb("POINT (1 2)") result = m.to_wkt(make_input(wkb)) assert isinstance(result, str) assert result == "POINT (1 2)" def test_to_wkt_normalises_wkt(): # WKT with non-canonical whitespace/casing is normalised when normalize_wkt=True assert m.to_wkt("point(1 2)", normalize_wkt=True) == "POINT (1 2)" def test_to_wkt_preserves_srid_from_wkt(): assert m.to_wkt("SRID=4326;POINT (1 2)") == "SRID=4326;POINT (1 2)" def test_to_wkt_preserves_srid_from_hex_wkb(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.to_wkt(hex_wkb) == "SRID=4326;POINT (1 2)" def test_to_wkt_preserves_srid_from_bytes_like_wkb(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.to_wkt(bytearray(wkb)) == "SRID=4326;POINT (1 2)" @pytest.mark.parametrize("wkt", [ "POINT (1 2)", "POINT Z (1 2 3)", "LINESTRING (0 0, 1 1)", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))", "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))", ]) def test_to_wkt_roundtrip_from_wkt(wkt): assert m.to_wkt(wkt) == wkt @pytest.mark.parametrize("wkt", [ "POINT (1 2)", "POINT Z (1 2 3)", "LINESTRING (0 0, 1 1)", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", "SRID=4326;POINT (1 2)", ]) def test_to_wkt_roundtrip_from_hex(wkt): hex_wkb = m.wkt_to_hex_wkb(wkt) assert m.to_wkt(hex_wkb) == wkt def test_to_wkt_invalid_raises_value_error(): with pytest.raises(ValueError): m.to_wkt("NOT_A_GEOMETRY (1 2)", normalize_wkt=True) def test_to_wkt_empty_string_raises(): with pytest.raises(ValueError): m.to_wkt("", normalize_wkt=True) def test_to_wkt_odd_length_hex_raises(): with pytest.raises(ValueError): m.to_wkt("ABC", normalize_wkt=True) def test_to_wkt_srid_zero_acts_as_strip(): result = m.to_wkt("SRID=4326;POINT (1 2)", srid=0, normalize_wkt=True) assert result == "POINT (1 2)" def test_to_wkt_srid_none_is_default(): ewkt = "SRID=4326;POINT (1 2)" assert m.to_wkt(ewkt) == m.to_wkt(ewkt, srid=None) def test_to_wkt_srid_none_mirrors_input_with_srid(): assert m.to_wkt("SRID=4326;POINT (1 2)", srid=None) == "SRID=4326;POINT (1 2)" def test_to_wkt_srid_none_mirrors_input_without_srid(): assert m.to_wkt("POINT (1 2)", srid=None) == "POINT (1 2)" def test_to_wkt_srid_false_strips_srid_from_wkt(): assert m.to_wkt("SRID=4326;POINT (1 2)", srid=False) == "POINT (1 2)" def test_to_wkt_srid_false_strips_srid_from_hex_wkb(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.to_wkt(hex_wkb, srid=False) == "POINT (1 2)" def test_to_wkt_srid_false_strips_srid_from_bytes_like_wkb(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.to_wkt(memoryview(wkb), srid=False) == "POINT (1 2)" def test_to_wkt_srid_false_normalises_wkt(): assert m.to_wkt("point(1 2)", srid=False, normalize_wkt=True) == "POINT (1 2)" def test_to_wkt_srid_false_no_srid_unchanged(): assert m.to_wkt("POINT (1 2)", srid=False) == "POINT (1 2)" def test_to_wkt_srid_int_adds_srid_to_plain_wkt(): assert m.to_wkt("POINT (1 2)", srid=4326) == "SRID=4326;POINT (1 2)" def test_to_wkt_srid_int_overrides_srid_in_ewkt(): assert m.to_wkt("SRID=4326;POINT (1 2)", srid=3857) == "SRID=3857;POINT (1 2)" def test_to_wkt_srid_int_adds_srid_to_plain_hex_wkb(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.to_wkt(hex_wkb, srid=4326) == "SRID=4326;POINT (1 2)" def test_to_wkt_srid_int_adds_srid_to_bytes_like_wkb(): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.to_wkt(bytearray(wkb), srid=4326) == "SRID=4326;POINT (1 2)" def test_to_wkt_srid_int_overrides_srid_in_hex_ewkb(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.to_wkt(hex_wkb, srid=3857) == "SRID=3857;POINT (1 2)" def test_to_wkt_normalize_wkt_false_bytes_like_wkb_still_normalises(): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.to_wkt(wkb, normalize_wkt=False) == "POINT (1 2)" def test_to_wkt_bytes_like_wkb_invalid_raises_value_error(): with pytest.raises(ValueError, match="invalid WKB"): m.to_wkt(b"\x99") def test_to_wkt_bytes_like_ascii_hex_is_raw_wkb_not_hex_text(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") with pytest.raises(ValueError, match="invalid WKB"): m.to_wkt(hex_wkb.encode("ascii")) def test_to_wkt_bytes_like_wkb_rejects_non_contiguous_memoryview(): wkb = m.wkt_to_wkb("POINT (1 2)") with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.to_wkt(memoryview(wkb)[::2]) def test_to_wkt_preserves_buffer_acquisition_errors(): view = memoryview(bytearray(b"\x99")) view.release() with pytest.raises(ValueError, match="released memoryview"): m.to_wkt(view) def test_to_wkt_accepts_source_keyword(): assert m.to_wkt(source="POINT (1 2)") == "POINT (1 2)" def test_to_wkt_srid_true_raises_value_error(): with pytest.raises(ValueError, match="srid=True is not valid"): m.to_wkt("POINT (1 2)", srid=True) # ── to_wkt: normalize_wkt parameter ───────────────────────────────────── def test_to_wkt_normalize_wkt_false_is_default(): assert m.to_wkt("point(1 2)") == m.to_wkt("point(1 2)", normalize_wkt=False) def test_to_wkt_normalize_wkt_true_normalises_casing(): assert m.to_wkt("point(1 2)", normalize_wkt=True) == "POINT (1 2)" def test_to_wkt_normalize_wkt_false_returns_wkt_as_is(): assert m.to_wkt("point(1 2)", normalize_wkt=False) == "point(1 2)" def test_to_wkt_normalize_wkt_false_auto_preserves_srid_prefix(): assert m.to_wkt("SRID=4326;POINT (1 2)", normalize_wkt=False) == "SRID=4326;POINT (1 2)" def test_to_wkt_normalize_wkt_false_strip_removes_srid_prefix(): assert m.to_wkt("SRID=4326;POINT (1 2)", srid=False, normalize_wkt=False) == "POINT (1 2)" def test_to_wkt_normalize_wkt_false_strip_noop_when_no_srid(): assert m.to_wkt("POINT (1 2)", srid=False, normalize_wkt=False) == "POINT (1 2)" def test_to_wkt_normalize_wkt_false_set_adds_srid_prefix(): assert m.to_wkt("POINT (1 2)", srid=4326, normalize_wkt=False) == "SRID=4326;POINT (1 2)" def test_to_wkt_normalize_wkt_false_set_overrides_srid_prefix(): assert m.to_wkt("SRID=4326;POINT (1 2)", srid=3857, normalize_wkt=False) == "SRID=3857;POINT (1 2)" def test_to_wkt_normalize_wkt_false_hex_input_still_normalises(): # normalize_wkt has no effect on hex WKB input; output is always normalised. hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.to_wkt(hex_wkb, normalize_wkt=False) == "POINT (1 2)" # ── to_hex_wkb ─────────────────────────────────────────────────────────── def test_to_hex_wkb_from_wkt_returns_uppercase_str(): result = m.to_hex_wkb("POINT (1 2)") assert isinstance(result, str) assert result == result.upper() def test_to_hex_wkb_from_wkt_matches_wkt_to_hex_wkb(): assert m.to_hex_wkb("POINT (1 2)") == m.wkt_to_hex_wkb("POINT (1 2)") def test_to_hex_wkb_from_hex_wkb_is_identity(): original = m.wkt_to_hex_wkb("LINESTRING (0 0, 1 1)") assert m.to_hex_wkb(original) == original @pytest.mark.parametrize("make_input", [ pytest.param(bytes, id="bytes"), pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), pytest.param(lambda wkb: memoryview(b"\x00" + wkb)[1:], id="sliced-memoryview"), pytest.param(lambda wkb: array("B", wkb), id="unsigned-byte-array"), ]) def test_to_hex_wkb_from_bytes_like_wkb_returns_uppercase_hex(make_input): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.to_hex_wkb(make_input(wkb)) == m.wkt_to_hex_wkb("POINT (1 2)") def test_to_hex_wkb_srid_none_is_default(): ewkt = "SRID=4326;POINT (1 2)" assert m.to_hex_wkb(ewkt) == m.to_hex_wkb(ewkt, srid=None) def test_to_hex_wkb_srid_none_preserves_srid(): result = m.to_hex_wkb("SRID=4326;POINT (1 2)", srid=None) assert m.hex_wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_hex_wkb_srid_false_strips_srid(): result = m.to_hex_wkb("SRID=4326;POINT (1 2)", srid=False) assert m.hex_wkb_to_wkt(result) == "POINT (1 2)" def test_to_hex_wkb_srid_false_strips_srid_from_bytes_like_wkb(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") result = m.to_hex_wkb(memoryview(wkb), srid=False) assert m.hex_wkb_to_wkt(result) == "POINT (1 2)" def test_to_hex_wkb_srid_int_adds_srid_to_plain_wkt(): result = m.to_hex_wkb("POINT (1 2)", srid=4326) assert m.hex_wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_hex_wkb_srid_int_adds_srid_to_bytes_like_wkb(): wkb = m.wkt_to_wkb("POINT (1 2)") result = m.to_hex_wkb(bytearray(wkb), srid=4326) assert m.hex_wkb_to_wkt(result) == "SRID=4326;POINT (1 2)" def test_to_hex_wkb_srid_int_overrides_srid(): result = m.to_hex_wkb("SRID=4326;POINT (1 2)", srid=3857) assert m.hex_wkb_to_wkt(result) == "SRID=3857;POINT (1 2)" def test_to_hex_wkb_srid_none_does_not_validate_bytes_like_wkb(): assert m.to_hex_wkb(b"\x99") == "99" def test_to_hex_wkb_bytes_like_ascii_hex_is_raw_wkb_not_hex_text(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.to_hex_wkb(hex_wkb.encode("ascii")) == hex_wkb.encode("ascii").hex().upper() def test_to_hex_wkb_accepts_source_keyword(): assert m.to_hex_wkb(source="POINT (1 2)") == m.wkt_to_hex_wkb("POINT (1 2)") def test_to_hex_wkb_rejects_non_buffer_non_str_input(): with pytest.raises(BufferError, match="contiguous one-byte buffer"): m.to_hex_wkb(123) def test_to_hex_wkb_srid_true_raises_value_error(): with pytest.raises(ValueError, match="srid=True is not valid"): m.to_hex_wkb("POINT (1 2)", srid=True) def test_to_hex_wkb_invalid_raises_value_error(): with pytest.raises(ValueError): m.to_hex_wkb("NOT_A_GEOMETRY (1 2)") # ── wkb_header_srid ────────────────────────────────────────────────────────── # Big-endian EWKB for SRID=4326;POINT (1 2) # 00=BE, 20000001=POINT|SRID (BE), 000010E6=4326 (BE), 3FF0…=1.0, 4000…=2.0 _BE_SRID_POINT_HEX = "0020000001000010E63FF00000000000004000000000000000" _BE_SRID3_POINT_HEX = "0020000001000000033FF00000000000004000000000000000" _BE_SRID4_POINT_HEX = "0020000001000000043FF00000000000004000000000000000" # Big-endian plain WKB for POINT (1 2) _BE_POINT_HEX = "00000000013FF00000000000004000000000000000" _LE_SRID_ZERO_POINT_HEX = "010100002000000000000000000000F03F0000000000000040" _LE_SRID_MINUS_ONE_POINT_HEX = "0101000020FFFFFFFF000000000000F03F0000000000000040" def test_wkb_header_srid_returns_int_from_binary(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_header_srid(wkb) == 4326 def test_wkb_header_srid_include_unknown_positive_srid_still_returns_int(): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_header_srid(wkb, include_unknown=True) == 4326 def test_wkb_header_srid_returns_none_when_no_srid(): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.wkb_header_srid(wkb) is None def test_wkb_header_srid_include_unknown_still_returns_none_when_no_srid(): wkb = m.wkt_to_wkb("POINT (1 2)") assert m.wkb_header_srid(wkb, include_unknown=True) is None def test_wkb_header_srid_default_treats_raw_zero_as_unknown(): wkb = bytes.fromhex(_LE_SRID_ZERO_POINT_HEX) assert m.wkb_header_srid(wkb) is None assert m.wkb_header_srid(wkb, include_unknown=False) is None def test_wkb_header_srid_include_unknown_returns_raw_zero(): wkb = bytes.fromhex(_LE_SRID_ZERO_POINT_HEX) assert m.wkb_header_srid(wkb, include_unknown=True) == 0 def test_wkb_header_srid_include_unknown_returns_signed_negative_one(): wkb = bytes.fromhex(_LE_SRID_MINUS_ONE_POINT_HEX) assert m.wkb_header_srid(wkb) is None assert m.wkb_header_srid(wkb, include_unknown=True) == -1 def test_wkb_header_srid_accepts_hex_string(): hex_wkb = m.wkt_to_hex_wkb("SRID=4326;POINT (1 2)") assert m.wkb_header_srid(hex_wkb) == 4326 def test_wkb_header_srid_include_unknown_accepts_hex_string(): assert m.wkb_header_srid(_LE_SRID_MINUS_ONE_POINT_HEX, include_unknown=True) == -1 def test_wkb_header_srid_hex_string_returns_none_when_no_srid(): hex_wkb = m.wkt_to_hex_wkb("POINT (1 2)") assert m.wkb_header_srid(hex_wkb) is None def test_wkb_header_srid_big_endian_fast_path(): assert m.wkb_header_srid(bytes.fromhex(_BE_SRID_POINT_HEX)) == 4326 def test_wkb_header_srid_big_endian_no_srid(): assert m.wkb_header_srid(bytes.fromhex(_BE_POINT_HEX)) is None def test_wkb_header_srid_big_endian_hex_string(): assert m.wkb_header_srid(_BE_SRID_POINT_HEX) == 4326 def test_wkb_header_srid_z_geometry(): wkb = m.wkt_to_wkb("SRID=4326;POINT Z (1 2 3)") assert m.wkb_header_srid(wkb) == 4326 def test_wkb_header_srid_multipolygon(): wkb = m.wkt_to_wkb("SRID=4326;MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))") assert m.wkb_header_srid(wkb) == 4326 def test_wkb_header_srid_geometry_collection(): wkb = m.wkt_to_wkb("SRID=4326;GEOMETRYCOLLECTION (POINT (1 2))") assert m.wkb_header_srid(wkb) == 4326 @pytest.mark.parametrize("make_input", [ pytest.param(bytearray, id="bytearray"), pytest.param(memoryview, id="memoryview"), pytest.param(lambda wkb: array("B", wkb), id="unsigned-byte-array"), ]) def test_wkb_header_srid_accepts_bytes_like(make_input): wkb = m.wkt_to_wkb("SRID=4326;POINT (1 2)") assert m.wkb_header_srid(make_input(wkb)) == 4326 def test_wkb_header_srid_iso_point_z_no_srid(): import struct # ISO WKB type code 1001 (POINT Z) has no EWKB flag bits, so the fast path # returns None immediately without a full parse. wkb = struct.pack(" str: ... def wkb_to_wkt_split_srid(wkb: _WkbInput) -> Tuple[str, Optional[int]]: ... def wkt_to_wkb(wkt: str, srid: _SridArg = None) -> bytes: ... def wkt_to_wkb_split_srid(wkt: str) -> Tuple[bytes, Optional[int]]: ... def wkt_to_hex_wkb(wkt: str, srid: _SridArg = None) -> str: ... def hex_wkb_to_wkt(hex_wkb: str, srid: _SridArg = None) -> str: ... def to_wkb(source: _Input, srid: _SridArg = None) -> bytes: ... def to_wkt( source: _Input, srid: _SridArg = None, normalize_wkt: bool = False, ) -> str: ... def to_hex_wkb(source: _Input, srid: _SridArg = None) -> str: ... def wkb_header_srid( source: _Input, *, include_unknown: bool = False, ) -> Optional[int]: ... @overload def to_wkb_no_srid_header(source: str) -> str: ... @overload def to_wkb_no_srid_header(source: _WkbInput) -> bytes: ... def to_wkb_no_srid_header(source: _Input) -> Union[bytes, str]: ... @overload def to_ewkb_header(source: str, srid: _HeaderSridArg) -> str: ... @overload def to_ewkb_header(source: _WkbInput, srid: _HeaderSridArg) -> bytes: ... def to_ewkb_header(source: _Input, srid: _HeaderSridArg) -> Union[bytes, str]: ... adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/000077500000000000000000000000001521227137000236545ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/Cargo.toml000066400000000000000000000010751521227137000256070ustar00rootroot00000000000000[package] name = "wkb_wkt_converter" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true description = "High-performance streaming converter between WKT and WKB geometry formats" documentation = "https://docs.rs/wkb_wkt_converter" readme = "../README.md" keywords = ["wkb", "wkt", "ewkb", "ewkt", "gis"] categories = ["science::geo", "parser-implementations", "encoding"] [lib] name = "wkb_wkt_converter" [dev-dependencies] # hex decoding for test fixtures hex = "0.4" adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/000077500000000000000000000000001521227137000244435ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/error.rs000066400000000000000000000023401521227137000261410ustar00rootroot00000000000000use std::fmt; #[derive(Debug, PartialEq)] pub enum Error { InvalidWkt(String), InvalidWkb(String), UnsupportedGeometryType(u32), } pub type Result = std::result::Result; impl fmt::Display for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Error::InvalidWkt(msg) => write!(f, "invalid WKT: {msg}"), Error::InvalidWkb(msg) => write!(f, "invalid WKB: {msg}"), Error::UnsupportedGeometryType(code) => { write!(f, "unsupported geometry type code: {code}") } } } } impl std::error::Error for Error {} #[cfg(test)] mod tests { use super::*; #[test] fn invalid_wkt_display() { assert_eq!( Error::InvalidWkt("bad input".into()).to_string(), "invalid WKT: bad input" ); } #[test] fn invalid_wkb_display() { assert_eq!( Error::InvalidWkb("truncated".into()).to_string(), "invalid WKB: truncated" ); } #[test] fn unsupported_type_display() { assert_eq!( Error::UnsupportedGeometryType(99).to_string(), "unsupported geometry type code: 99" ); } } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/lib.rs000066400000000000000000001116041521227137000255620ustar00rootroot00000000000000pub mod error; pub mod types; mod wkb_to_wkt; mod wkt_to_wkb; use std::borrow::Cow; use types::{EWKB_M, EWKB_SRID, EWKB_Z}; pub use error::{Error, Result}; pub use types::{Dimension, GeomType}; /// Converts WKB/EWKB bytes to a WKT/EWKT string. /// /// `srid` controls SRID handling in the output: /// - [`SridMode::Auto`]: mirror the input — `SRID=N;` prefix kept if present. /// - [`SridMode::Strip`]: always strip the `SRID=N;` prefix from the output. /// - [`SridMode::Set(n)`]: always prepend `SRID=n;`, overriding any SRID in the input. pub fn wkb_to_wkt(wkb: &[u8], srid: SridMode) -> Result { let srid = normalise_srid_mode(srid); match srid { SridMode::Auto => wkb_to_wkt::convert(wkb), SridMode::Strip => wkb_to_wkt_split_srid(wkb).map(|(wkt, _)| wkt), SridMode::Set(srid_val) => { let (wkt, _) = wkb_to_wkt_split_srid(wkb)?; Ok(format!("SRID={srid_val};{wkt}")) } } } /// Converts WKB/EWKB bytes to a WKT string, returning the SRID separately. /// The returned WKT string does not include a `SRID=N;` prefix. pub fn wkb_to_wkt_split_srid(wkb: &[u8]) -> Result<(String, Option)> { wkb_to_wkt::convert_split_srid(wkb) } /// Converts a WKT/EWKT string to EWKB bytes. /// /// `srid` controls SRID handling in the output: /// - [`SridMode::Auto`]: mirror the input — SRID kept if present, absent if not. /// - [`SridMode::Strip`]: always strip the SRID from the output. /// - [`SridMode::Set(n)`]: always embed SRID `n`, overriding any SRID in the input. pub fn wkt_to_wkb(wkt: &str, srid: SridMode) -> Result> { let srid = normalise_srid_mode(srid); match srid { SridMode::Auto => wkt_to_wkb::convert(wkt), SridMode::Strip => wkt_to_wkb_split_srid(wkt).map(|(wkb, _)| wkb), SridMode::Set(srid_val) => wkt_to_wkb::convert_with_forced_srid(wkt, srid_val), } } /// Converts a WKT/EWKT string to EWKB bytes, returning the SRID separately. /// The SRID is not embedded in the returned bytes. pub fn wkt_to_wkb_split_srid(wkt: &str) -> Result<(Vec, Option)> { wkt_to_wkb::convert_split_srid(wkt) } /// Converts a WKT/EWKT string to an uppercase hex-encoded EWKB string. /// /// `srid` controls SRID handling in the output: /// - [`SridMode::Auto`]: mirror the input — SRID kept if present, absent if not. /// - [`SridMode::Strip`]: always strip the SRID from the output. /// - [`SridMode::Set(n)`]: always embed SRID `n`, overriding any SRID in the input. pub fn wkt_to_hex_wkb(wkt: &str, srid: SridMode) -> Result { let srid = normalise_srid_mode(srid); match srid { SridMode::Auto => wkt_to_wkb::convert_to_hex(wkt), SridMode::Strip => wkt_to_wkb::convert_to_hex_split_srid(wkt), SridMode::Set(srid_val) => wkt_to_wkb::convert_to_hex_with_forced_srid(wkt, srid_val), } } /// Converts a hex-encoded WKB/EWKB string to a WKT/EWKT string. /// /// `srid` controls SRID handling in the output: /// - [`SridMode::Auto`]: mirror the input — `SRID=N;` prefix kept if present. /// - [`SridMode::Strip`]: always strip the `SRID=N;` prefix from the output. /// - [`SridMode::Set(n)`]: always prepend `SRID=n;`, overriding any SRID in the input. pub fn hex_wkb_to_wkt(hex: &str, srid: SridMode) -> Result { let bytes = decode_hex(hex)?; let srid = normalise_srid_mode(srid); match srid { SridMode::Auto => wkb_to_wkt::convert(&bytes), SridMode::Strip => wkb_to_wkt_split_srid(&bytes).map(|(wkt, _)| wkt), SridMode::Set(srid_val) => { let (wkt, _) = wkb_to_wkt_split_srid(&bytes)?; Ok(format!("SRID={srid_val};{wkt}")) } } } /// Controls SRID handling in the output of direct and generic converters. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SridMode { /// Mirror the input: SRID is kept if present, absent if not. #[default] Auto, /// Always strip any SRID from the output. Strip, /// Always embed this SRID in the output, overriding whatever the input contains. /// Values ≤ 0 are treated as unknown per the PostGIS convention /// (`SRID_IS_UNKNOWN(x) ((int)x<=0)`) and behave identically to [`SridMode::Strip`]. Set(i32), } /// Reads the SRID embedded in the top-level EWKB header without converting /// the full geometry. /// /// For any byte order and any type word whose high bits contain only the /// three canonical EWKB flags (Z, M, SRID), the SRID is read directly from /// the 9-byte header without parsing the geometry body. The base type code /// (low 16 bits) is **not** validated; the function returns an SRID even for /// unrecognised type codes as long as the upper 16 bits are only EWKB flags. /// ISO-dimensional type codes such as 1001 carry no EWKB flag bits, so /// `None` is returned immediately without a full parse. /// /// Inputs shorter than the 5-byte WKB header, inputs with unknown flag bits /// in the high half of the type word, and inputs with an invalid byte-order /// marker fall back to a full [`wkb_to_wkt_split_srid`] parse. /// /// Returns `None` when no SRID is embedded in the top-level header, including /// raw EWKB SRID values that are non-positive. pub fn wkb_header_srid(wkb: &[u8]) -> Result> { wkb_header_srid_impl(wkb, false) } /// Reads the SRID embedded in the top-level EWKB header without converting /// the full geometry, preserving raw unknown SRID values. /// /// This behaves like [`wkb_header_srid`] except that a present EWKB SRID flag /// with raw SRID `0` returns `Some(0)` and raw SRID `0xffffffff` returns /// `Some(-1)`, following the crate's signed `i32` SRID model. A header with no /// EWKB SRID flag still returns `None`. pub fn wkb_header_srid_including_unknown(wkb: &[u8]) -> Result> { wkb_header_srid_impl(wkb, true) } fn wkb_header_srid_impl(wkb: &[u8], include_unknown: bool) -> Result> { match try_read_ewkb_srid_fast(wkb, include_unknown) { Some(r) => r, None => wkb_to_wkt_split_srid(wkb).map(|(_, srid)| srid), } } /// Strips the top-level SRID flag and SRID field from a WKB/EWKB byte slice. /// /// For simple Point, LineString, or Polygon EWKB/WKB headers in either byte /// order, the header is rewritten without parsing the geometry body; inputs /// without an SRID on this fast path are returned byte-for-byte unchanged. /// Collection types, ISO-dimensional inputs, and non-canonical headers fall back /// to a full WKB→WKT→WKB round-trip which normalises the representation and /// preserves the invariant that only the top-level header may carry an SRID; /// these inputs may be rewritten even when no SRID was present. pub fn wkb_strip_srid(wkb: &[u8]) -> Result> { apply_srid_to_wkb(wkb, SridMode::Strip).map(|c| c.into_owned()) } /// Embeds or replaces the SRID in a WKB/EWKB byte slice. /// /// SRID values ≤ 0 are treated as unknown per the PostGIS convention /// (`SRID_IS_UNKNOWN(x) ((int)x<=0)`) and strip the SRID instead, identical /// to [`wkb_strip_srid`]. /// /// For simple Point, LineString, or Polygon EWKB/WKB headers in either byte /// order, the header is rewritten without parsing the geometry body. /// Collection types, ISO-dimensional inputs, and non-canonical headers fall back /// to a full WKB→WKT→WKB round-trip which normalises the representation and /// preserves the invariant that only the top-level header may carry an SRID. pub fn wkb_set_srid(wkb: &[u8], srid: i32) -> Result> { apply_srid_to_wkb(wkb, normalise_srid_mode(SridMode::Set(srid))).map(|c| c.into_owned()) } /// Normalises `SridMode::Set(n)` with n ≤ 0 to `SridMode::Strip`. /// /// Per the PostGIS convention (`SRID_IS_UNKNOWN(x) ((int)x<=0)`), SRID values ≤ 0 /// mean "unknown / no spatial reference" and must not appear in EWKT output. fn normalise_srid_mode(mode: SridMode) -> SridMode { match mode { SridMode::Set(n) if n <= 0 => SridMode::Strip, other => other, } } /// Explicit input selector for generic converters. /// /// [`Input::Text`] preserves the existing generic converter behavior: WKT/EWKT /// text is parsed as text, while non-empty even-length all-hex text is treated /// as hex-encoded WKB/EWKB. /// /// [`Input::Wkb`] is always treated as raw WKB/EWKB bytes. The bytes are never /// interpreted as hex text. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Input<'a> { /// WKT/EWKT text, or hex-encoded WKB/EWKB text. Text(&'a str), /// Raw WKB/EWKB bytes. Wkb(&'a [u8]), } /// Converts WKT/EWKT text, hex-encoded WKB/EWKB text, or raw WKB/EWKB bytes to WKB bytes. /// /// The input format is detected automatically: a non-empty string with even /// length composed entirely of hexadecimal characters is treated as hex WKB; /// anything else (including odd-length all-hex strings) is treated as WKT. /// Raw [`Input::Wkb`] bytes are always treated as WKB/EWKB, never as hex text. /// /// `srid` controls SRID handling in the output: /// - [`SridMode::Auto`] (default): mirror the input — SRID kept if present, absent if not. /// When the input is hex WKB text or raw WKB bytes, the bytes are returned /// as-is without WKB structure validation. /// - [`SridMode::Strip`]: always strip the SRID from the output. /// - [`SridMode::Set(n)`]: always embed SRID `n`, overriding any SRID in the input. /// /// **Note on validation:** for simple Point, LineString, or Polygon EWKB/WKB /// hex text or raw WKB input in either byte order under `Strip` and `Set`, the /// fast path rewrites only the top-level header. Collection types, /// ISO-dimensional inputs, and non-canonical headers fall back to a full parse /// round-trip which normalises the geometry body and nested headers. pub fn to_wkb(input: Input<'_>, srid: SridMode) -> Result> { match input { Input::Text(text) => to_wkb_text(text, srid), Input::Wkb(wkb) => to_wkb_bytes(wkb, srid), } } fn to_wkb_text(text: &str, srid: SridMode) -> Result> { let srid = normalise_srid_mode(srid); let trimmed = text.trim(); match srid { SridMode::Auto => { if let Some(bytes) = try_decode_hex(trimmed) { Ok(bytes) } else { wkt_to_wkb::convert(trimmed) } } SridMode::Strip => { if let Some(bytes) = try_decode_hex(trimmed) { to_wkb_owned_bytes(bytes, srid) } else { wkt_to_wkb_split_srid(trimmed).map(|(b, _)| b) } } SridMode::Set(srid_val) => { if let Some(bytes) = try_decode_hex(trimmed) { to_wkb_owned_bytes(bytes, srid) } else { wkt_to_wkb::convert_with_forced_srid(trimmed, srid_val) } } } } fn to_wkb_owned_bytes(wkb: Vec, srid: SridMode) -> Result> { match apply_srid_to_wkb(&wkb, srid)? { Cow::Borrowed(_) => Ok(wkb), Cow::Owned(out) => Ok(out), } } fn to_wkb_bytes(wkb: &[u8], srid: SridMode) -> Result> { Ok(apply_srid_to_wkb(wkb, normalise_srid_mode(srid))?.into_owned()) } fn apply_srid_to_wkb(wkb: &[u8], srid: SridMode) -> Result> { match srid { SridMode::Auto => Ok(Cow::Borrowed(wkb)), SridMode::Strip => match try_strip_srid_from_simple_wkb(wkb) { Some(Cow::Borrowed(_)) => Ok(Cow::Borrowed(wkb)), Some(Cow::Owned(out)) => Ok(Cow::Owned(out)), None => { let (wkt, _) = wkb_to_wkt_split_srid(wkb)?; wkt_to_wkb::convert(&wkt).map(Cow::Owned) } }, SridMode::Set(srid_val) => match try_set_srid_in_simple_wkb(wkb, srid_val) { Some(Cow::Borrowed(_)) => Ok(Cow::Borrowed(wkb)), Some(Cow::Owned(out)) => Ok(Cow::Owned(out)), None => { let (wkt, _) = wkb_to_wkt_split_srid(wkb)?; wkt_to_wkb::convert_with_forced_srid(&wkt, srid_val).map(Cow::Owned) } }, } } /// Converts any WKT/EWKT string, hex-encoded WKB/EWKB string, or raw WKB bytes to a WKT /// string. /// /// The input format is detected automatically: a non-empty string with even /// length composed entirely of hexadecimal characters is treated as hex WKB; /// anything else (including odd-length all-hex strings) is treated as WKT. /// Raw [`Input::Wkb`] bytes are always treated as WKB/EWKB, never as hex text. /// /// `srid` controls SRID handling in the output: /// - [`SridMode::Auto`] (default): mirror the input — `SRID=N;` prefix kept if present. /// - [`SridMode::Strip`]: always strip the `SRID=N;` prefix from the output. /// - [`SridMode::Set(n)`]: always prepend `SRID=n;`, overriding any SRID in the input. /// /// `normalize_wkt` controls whether WKT input is normalised (canonical casing, /// spacing, and coordinate formatting) via a round-trip through WKB. Pass /// `false` to skip normalisation (only the SRID prefix is adjusted), which /// avoids the encoding overhead. **When `false`, WKT input is not validated — /// malformed WKT is returned without error.** Note that leading/trailing /// whitespace is always trimmed regardless of this flag. An empty input string /// always returns an error regardless of this flag. Has no effect when the /// input is hex WKB or raw WKB, which is always decoded to normalised WKT. pub fn to_wkt(input: Input<'_>, srid: SridMode, normalize_wkt: bool) -> Result { match input { Input::Text(text) => to_wkt_text(text, srid, normalize_wkt), Input::Wkb(wkb) => wkb_to_wkt(wkb, srid), } } fn to_wkt_text(text: &str, srid: SridMode, normalize_wkt: bool) -> Result { let srid = normalise_srid_mode(srid); let trimmed = text.trim(); if trimmed.is_empty() { return Err(Error::InvalidWkt("empty input".into())); } // Fast path for WKT input with normalisation disabled. // All geometry type keywords supported by this library (POINT, LINESTRING, // POLYGON, MULTI*, GEOMETRYCOLLECTION) and the SRID= prefix start with a // letter that is not an ASCII hex digit, so the first byte is a reliable // O(1) discriminator. (Note: extended keywords like CIRCULARSTRING start // with 'C' which IS a hex digit; those fall through to try_decode_hex, which // returns None for non-hex body characters. They are then handled as WKT- // like text: returned as-is when normalisation is disabled, or parsed as // WKT when normalisation is enabled.) if !normalize_wkt && trimmed .as_bytes() .first() .is_some_and(|b| !b.is_ascii_hexdigit()) { return Ok(match srid { SridMode::Auto => strip_unknown_srid_prefix(trimmed).to_owned(), SridMode::Strip => strip_ewkt_prefix(trimmed).to_owned(), SridMode::Set(srid_val) => { format!("SRID={srid_val};{}", strip_ewkt_prefix(trimmed)) } }); } // Hex WKB is always decoded to normalised WKT. if let Some(decoded) = try_decode_hex(trimmed) { return match srid { SridMode::Auto => wkb_to_wkt::convert(&decoded), SridMode::Strip => wkb_to_wkt_split_srid(&decoded).map(|(s, _)| s), SridMode::Set(srid_val) => { let (plain, _) = wkb_to_wkt_split_srid(&decoded)?; Ok(format!("SRID={srid_val};{plain}")) } }; } // WKT input whose first byte happens to be a hex digit (e.g. starts with // a digit), but failed hex decoding. With normalisation disabled, return // as-is (no validation). if !normalize_wkt { return Ok(match srid { SridMode::Auto => trimmed.to_owned(), SridMode::Strip => strip_ewkt_prefix(trimmed).to_owned(), SridMode::Set(srid_val) => { format!("SRID={srid_val};{}", strip_ewkt_prefix(trimmed)) } }); } // WKT input, normalisation enabled. match srid { SridMode::Auto => { let wkb = wkt_to_wkb::convert(trimmed)?; wkb_to_wkt::convert(&wkb) } SridMode::Strip => { let (wkb, _) = wkt_to_wkb_split_srid(trimmed)?; wkb_to_wkt::convert(&wkb) } SridMode::Set(srid_val) => { let wkb = wkt_to_wkb::convert_with_forced_srid(trimmed, srid_val)?; wkb_to_wkt::convert(&wkb) } } } /// Converts any WKT/EWKT string, hex-encoded WKB/EWKB string, or raw WKB bytes to an uppercase /// hex-encoded EWKB string. /// /// The text input format is detected automatically. Raw [`Input::Wkb`] bytes /// are always treated as WKB/EWKB, never as hex text. /// /// `srid` controls SRID handling in the output — see [`SridMode`]. /// /// **Note:** hex text input under [`SridMode::Auto`] is validated as hex text /// and uppercased without WKB structure validation. Raw WKB input under /// [`SridMode::Auto`] is hex-encoded without WKB structure validation. pub fn to_hex_wkb(input: Input<'_>, srid: SridMode) -> Result { match input { Input::Text(text) => to_hex_wkb_text(text, srid), Input::Wkb(wkb) => to_hex_wkb_bytes(wkb, srid), } } fn to_hex_wkb_text(text: &str, srid: SridMode) -> Result { let srid = normalise_srid_mode(srid); let trimmed = text.trim(); if srid == SridMode::Auto { if let Some(hex) = try_normalize_hex_uppercase(trimmed) { return Ok(hex); } } match srid { SridMode::Auto => {} SridMode::Strip => { if let Some(bytes) = try_decode_hex(trimmed) { return to_hex_wkb_bytes(&bytes, srid); } } SridMode::Set(_) => { if let Some(bytes) = try_decode_hex(trimmed) { return to_hex_wkb_bytes(&bytes, srid); } } } match srid { SridMode::Auto => wkt_to_wkb::convert_to_hex(trimmed), SridMode::Strip => wkt_to_wkb::convert_to_hex_split_srid(trimmed), SridMode::Set(srid_val) => wkt_to_wkb::convert_to_hex_with_forced_srid(trimmed, srid_val), } } fn to_hex_wkb_bytes(wkb: &[u8], srid: SridMode) -> Result { let srid = normalise_srid_mode(srid); match srid { SridMode::Auto => Ok(encode_hex(wkb)), SridMode::Strip => match try_strip_srid_from_simple_wkb(wkb) { Some(Cow::Borrowed(_)) => Ok(encode_hex(wkb)), Some(Cow::Owned(out)) => Ok(encode_hex(&out)), None => { let (wkt, _) = wkb_to_wkt_split_srid(wkb)?; wkt_to_wkb::convert_to_hex_split_srid(&wkt) } }, SridMode::Set(srid_val) => match try_set_srid_in_simple_wkb(wkb, srid_val) { Some(Cow::Borrowed(_)) => Ok(encode_hex(wkb)), Some(Cow::Owned(out)) => Ok(encode_hex(&out)), None => { let (wkt, _) = wkb_to_wkt_split_srid(wkb)?; wkt_to_wkb::convert_to_hex_with_forced_srid(&wkt, srid_val) } }, } } /// Strips a `SRID=N;` prefix only when N ≤ 0 (PostGIS "unknown SRID"). /// Returns the input unchanged if the prefix is absent or has a positive SRID. fn strip_unknown_srid_prefix(wkt: &str) -> &str { if wkt .get(..5) .is_some_and(|p| p.eq_ignore_ascii_case("SRID=")) { if let Some(rel) = wkt[5..].find(';') { if let Ok(n) = wkt[5..5 + rel].trim().parse::() { if n <= 0 { return wkt[6 + rel..].trim_start(); } } } } wkt } fn strip_ewkt_prefix(wkt: &str) -> &str { if wkt .get(..5) .is_some_and(|p| p.eq_ignore_ascii_case("SRID=")) { if let Some(rel) = wkt[5..].find(';') { return wkt[6 + rel..].trim_start(); } } wkt } /// Fast path for reading the SRID from LE or BE WKB/EWKB headers. /// /// Handles any byte order and any geometry type with only the three canonical /// EWKB flag bits (Z, M, SRID) in the high half of the type word. This /// covers standard EWKB (types 1–7), ISO WKB (type codes ≥ 1000 which have /// no high bits set and therefore no SRID flag), and plain WKB. /// /// Returns `None` when the slice is shorter than the 5-byte WKB header, when /// the high bits contain flags beyond Z/M/SRID (unrecognised format), or when /// the byte-order marker is invalid, so the caller can fall back to a full /// parse. /// /// Returns `Some(Err(...))` when the SRID flag is set but the slice is too /// short to contain the 4-byte SRID field. fn try_read_ewkb_srid_fast(wkb: &[u8], include_unknown: bool) -> Option>> { if wkb.len() < 5 { return None; } let little_endian = match wkb[0] { 1 => true, 0 => false, _ => return None, }; let type_bytes: [u8; 4] = wkb[1..5].try_into().unwrap(); let type_u32 = if little_endian { u32::from_le_bytes(type_bytes) } else { u32::from_be_bytes(type_bytes) }; let high_bits = type_u32 & !0x0000_FFFF; if high_bits & !(EWKB_Z | EWKB_M | EWKB_SRID) != 0 { return None; // Unknown flag bits — format unrecognised, fall back } // If the SRID flag is not set there is no SRID in this header. This // covers plain WKB, EWKB without SRID, and ISO WKB (no EWKB flags at all). if type_u32 & EWKB_SRID == 0 { return Some(Ok(None)); } if wkb.len() < 9 { return Some(Err(Error::InvalidWkb( "EWKB header has SRID flag but is too short to contain the SRID field".into(), ))); } let srid_bytes: [u8; 4] = wkb[5..9].try_into().unwrap(); let srid = if little_endian { i32::from_le_bytes(srid_bytes) } else { i32::from_be_bytes(srid_bytes) }; Some(Ok((include_unknown || srid > 0).then_some(srid))) } /// Encode bytes as an uppercase hexadecimal string using a lookup table. /// /// This is substantially faster than the `write!(s, "{b:02X}")` approach /// because it avoids invoking the format machinery for every byte. #[doc(hidden)] pub fn encode_hex(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789ABCDEF"; let mut out = vec![0u8; bytes.len().saturating_mul(2)]; for (chunk, &b) in out.chunks_exact_mut(2).zip(bytes) { chunk[0] = HEX[(b >> 4) as usize]; chunk[1] = HEX[(b & 0xF) as usize]; } // SAFETY: every byte written is an ASCII hex digit (0–9, A–F). unsafe { String::from_utf8_unchecked(out) } } /// 256-entry lookup table mapping ASCII bytes to their hex nibble value (0–15) /// or to 0xFF for invalid characters. Built at compile time; a single array /// index replaces the 3-arm range match in the hot decode loop, eliminating /// branch mispredictions for inputs whose hex characters are not clustered in /// the '0'–'9' range (e.g. trig-value coordinates where A–F appear ~50 % of /// the time). const fn build_hex_nibble_lut() -> [u8; 256] { let mut lut = [0xFF_u8; 256]; let mut i = 0u8; while i <= 9 { lut[(b'0' + i) as usize] = i; i += 1; } let mut i = 0u8; while i < 6 { lut[(b'a' + i) as usize] = 10 + i; lut[(b'A' + i) as usize] = 10 + i; i += 1; } lut } static HEX_NIBBLE_LUT: [u8; 256] = build_hex_nibble_lut(); /// Try to decode `s` as a hex string, combining detection and decoding. /// /// Returns `None` if `s` is empty, has odd length, or contains any /// non-hexadecimal character. Allocation is delayed until the first byte pair /// has been proven to be valid hex, so obvious non-hex input avoids allocation /// while valid hex still decodes in one pass over the remaining pairs. fn try_decode_hex(s: &str) -> Option> { let bytes = s.as_bytes(); if bytes.is_empty() || !bytes.len().is_multiple_of(2) { return None; } let hi = HEX_NIBBLE_LUT[bytes[0] as usize]; let lo = HEX_NIBBLE_LUT[bytes[1] as usize]; if (hi | lo) > 0x0F { return None; } let mut out = vec![0u8; bytes.len() / 2]; out[0] = (hi << 4) | lo; for (dst, chunk) in out[1..].iter_mut().zip(bytes[2..].chunks_exact(2)) { let hi = HEX_NIBBLE_LUT[chunk[0] as usize]; let lo = HEX_NIBBLE_LUT[chunk[1] as usize]; if (hi | lo) > 0x0F { return None; } *dst = (hi << 4) | lo; } Some(out) } fn try_normalize_hex_uppercase(s: &str) -> Option { let bytes = s.as_bytes(); if bytes.is_empty() || !bytes.len().is_multiple_of(2) { return None; } let first = HEX_NIBBLE_LUT[bytes[0] as usize]; if first > 0x0F { return None; } let mut out = vec![0u8; bytes.len()]; out[0] = bytes[0].to_ascii_uppercase(); for (dst, &byte) in out[1..].iter_mut().zip(&bytes[1..]) { if HEX_NIBBLE_LUT[byte as usize] > 0x0F { return None; } *dst = byte.to_ascii_uppercase(); } // SAFETY: every byte is an ASCII hex digit, uppercased in place. Some(unsafe { String::from_utf8_unchecked(out) }) } /// Decode a hex string with detailed position-aware error messages. /// Used by [`hex_wkb_to_wkt`] where the caller knows the input must be hex. #[doc(hidden)] pub fn decode_hex(hex: &str) -> Result> { let bytes = hex.as_bytes(); if bytes.is_empty() { return Err(Error::InvalidWkb("empty hex string".into())); } if !bytes.len().is_multiple_of(2) { return Err(Error::InvalidWkb("hex string has odd length".into())); } let mut out = vec![0u8; bytes.len() / 2]; for (i, (dst, chunk)) in out.iter_mut().zip(bytes.chunks_exact(2)).enumerate() { let hi = HEX_NIBBLE_LUT[chunk[0] as usize]; if hi > 0x0F { return Err(Error::InvalidWkb(format!( "invalid hex digit at position {}", i * 2 ))); } let lo = HEX_NIBBLE_LUT[chunk[1] as usize]; if lo > 0x0F { return Err(Error::InvalidWkb(format!( "invalid hex digit at position {}", i * 2 + 1 ))); } *dst = (hi << 4) | lo; } Ok(out) } #[derive(Debug, Clone, Copy)] struct SimpleFastPathHeader { byte_order: u8, little_endian: bool, type_u32: u32, canonical_type_without_srid: u32, } impl SimpleFastPathHeader { fn u32_bytes(self, value: u32) -> [u8; 4] { if self.little_endian { value.to_le_bytes() } else { value.to_be_bytes() } } fn i32_bytes(self, value: i32) -> [u8; 4] { if self.little_endian { value.to_le_bytes() } else { value.to_be_bytes() } } fn read_srid(self, bytes: &[u8]) -> i32 { let srid_bytes: [u8; 4] = bytes[5..9].try_into().unwrap(); if self.little_endian { i32::from_le_bytes(srid_bytes) } else { i32::from_be_bytes(srid_bytes) } } } fn try_read_simple_fast_path_header(bytes: &[u8]) -> Option { if bytes.len() < 5 { return None; } let little_endian = match bytes[0] { 1 => true, 0 => false, _ => return None, }; let type_bytes: [u8; 4] = bytes[1..5].try_into().unwrap(); let type_u32 = if little_endian { u32::from_le_bytes(type_bytes) } else { u32::from_be_bytes(type_bytes) }; let high_bits = type_u32 & !0x0000_FFFF; if high_bits & !(EWKB_Z | EWKB_M | EWKB_SRID) != 0 { return None; } let base_geom_type = type_u32 & 0x0000_FFFF; if !(1..=3).contains(&base_geom_type) { return None; } if type_u32 & EWKB_SRID != 0 && bytes.len() < 9 { return None; } Some(SimpleFastPathHeader { byte_order: bytes[0], little_endian, type_u32, canonical_type_without_srid: type_u32 & !EWKB_SRID, }) } enum WkbWriterKind<'a> { Copy(&'a [u8]), Strip { byte_order: u8, type_bytes: [u8; 4], body: &'a [u8], }, Set { byte_order: u8, type_bytes: [u8; 4], srid_bytes: [u8; 4], body: &'a [u8], }, Parsed(Vec), } /// Write token returned by [`wkb_strip_srid_writer`] and [`wkb_set_srid_writer`]. /// /// Callers receive a `(output_size, WkbWriter)` pair. Allocate a buffer of /// exactly `output_size` bytes and pass it to [`WkbWriter::write_into`] to fill it. /// /// Fast-path variants carry only stack-allocated references into the input /// slice; no heap allocation is performed unless the slow-path full parse is /// required. pub struct WkbWriter<'a>(WkbWriterKind<'a>); impl WkbWriter<'_> { /// Fills `buf` with the SRID-modified WKB bytes. /// /// `buf` must be exactly the length returned alongside this writer; /// passing a buffer of the wrong size panics. pub fn write_into(self, buf: &mut [u8]) { match self.0 { WkbWriterKind::Copy(src) => { debug_assert_eq!(buf.len(), src.len(), "WkbWriter::write_into: buffer length does not match the size returned by the writer"); buf.copy_from_slice(src); } WkbWriterKind::Strip { byte_order, type_bytes, body, } => { debug_assert_eq!(buf.len(), 5 + body.len(), "WkbWriter::write_into: buffer length does not match the size returned by the writer"); buf[0] = byte_order; buf[1..5].copy_from_slice(&type_bytes); buf[5..].copy_from_slice(body); } WkbWriterKind::Set { byte_order, type_bytes, srid_bytes, body, } => { debug_assert_eq!(buf.len(), 9 + body.len(), "WkbWriter::write_into: buffer length does not match the size returned by the writer"); buf[0] = byte_order; buf[1..5].copy_from_slice(&type_bytes); buf[5..9].copy_from_slice(&srid_bytes); buf[9..].copy_from_slice(body); } WkbWriterKind::Parsed(data) => { debug_assert_eq!(buf.len(), data.len(), "WkbWriter::write_into: buffer length does not match the size returned by the writer"); buf.copy_from_slice(&data); } } } } /// Returns the output byte count and a writer for stripping the SRID from a /// WKB/EWKB byte slice. /// /// Call [`WkbWriter::write_into`] with a mutable slice of exactly the returned /// length to fill it with the SRID-stripped representation. /// /// For simple Point, LineString, or Polygon EWKB/WKB headers in either byte /// order, the header is rewritten without parsing the geometry body. /// Collection types, ISO-dimensional inputs, and non-canonical headers fall back /// to a full WKB→WKT→WKB round-trip. /// /// Prefer [`wkb_strip_srid`] when a `Vec` is convenient; use this /// function to write directly into a caller-supplied buffer (e.g. a Python /// `bytes` object via `PyBytes::new_with`). pub fn wkb_strip_srid_writer(wkb: &[u8]) -> Result<(usize, WkbWriter<'_>)> { if let Some(header) = try_read_simple_fast_path_header(wkb) { if header.type_u32 & EWKB_SRID == 0 { // No SRID — output is identical to input. return Ok((wkb.len(), WkbWriter(WkbWriterKind::Copy(wkb)))); } // Strip SRID: remove 4-byte SRID field, clear SRID flag. let type_bytes = header.u32_bytes(header.canonical_type_without_srid); return Ok(( wkb.len() - 4, WkbWriter(WkbWriterKind::Strip { byte_order: header.byte_order, type_bytes, body: &wkb[9..], }), )); } let (wkt, _) = wkb_to_wkt_split_srid(wkb)?; let result = wkt_to_wkb::convert(&wkt)?; let out_len = result.len(); Ok((out_len, WkbWriter(WkbWriterKind::Parsed(result)))) } /// Returns the output byte count and a writer for embedding or replacing the /// SRID in a WKB/EWKB byte slice. /// /// SRID values ≤ 0 are treated as unknown per the PostGIS convention /// (`SRID_IS_UNKNOWN(x) ((int)x<=0)`) and strip the SRID, identical to /// [`wkb_strip_srid_writer`]. /// /// Call [`WkbWriter::write_into`] with a mutable slice of exactly the returned /// length to fill it with the updated representation. /// /// For simple Point, LineString, or Polygon EWKB/WKB headers in either byte /// order, the header is rewritten without parsing the geometry body. /// Collection types, ISO-dimensional inputs, and non-canonical headers fall back /// to a full WKB→WKT→WKB round-trip. /// /// Prefer [`wkb_set_srid`] when a `Vec` is convenient; use this /// function to write directly into a caller-supplied buffer. pub fn wkb_set_srid_writer(wkb: &[u8], srid: i32) -> Result<(usize, WkbWriter<'_>)> { if srid <= 0 { return wkb_strip_srid_writer(wkb); } if let Some(header) = try_read_simple_fast_path_header(wkb) { let type_with_srid = header.canonical_type_without_srid | EWKB_SRID; let srid_bytes = header.i32_bytes(srid); if header.type_u32 & EWKB_SRID != 0 { // SRID flag already set. let stored = header.read_srid(wkb); if stored == srid { // No-op — SRID already matches. return Ok((wkb.len(), WkbWriter(WkbWriterKind::Copy(wkb)))); } // Replace SRID in-place (same output length). let type_bytes = header.u32_bytes(type_with_srid); return Ok(( wkb.len(), WkbWriter(WkbWriterKind::Set { byte_order: header.byte_order, type_bytes, srid_bytes, body: &wkb[9..], }), )); } // No SRID yet — insert 4-byte SRID field, set SRID flag. let type_bytes = header.u32_bytes(type_with_srid); return Ok(( wkb.len() + 4, WkbWriter(WkbWriterKind::Set { byte_order: header.byte_order, type_bytes, srid_bytes, body: &wkb[5..], }), )); } let (wkt, _) = wkb_to_wkt_split_srid(wkb)?; let result = wkt_to_wkb::convert_with_forced_srid(&wkt, srid)?; let out_len = result.len(); Ok((out_len, WkbWriter(WkbWriterKind::Parsed(result)))) } /// Strip the SRID from a simple EWKB/WKB byte slice without parsing the /// geometry body. /// /// Returns `None` when the bytes are not recognisable as a simple Point, /// LineString, or Polygon EWKB/WKB header in either byte order. In those cases /// the caller should fall back to a full WKB→WKT→WKB round-trip, which /// normalises type codes and handles collection or ISO-dimensional input. fn try_strip_srid_from_simple_wkb(bytes: &[u8]) -> Option> { let header = try_read_simple_fast_path_header(bytes)?; if header.type_u32 & EWKB_SRID == 0 { return Some(Cow::Borrowed(bytes)); } let mut out = Vec::with_capacity(bytes.len() - 4); out.push(header.byte_order); out.extend_from_slice(&header.u32_bytes(header.canonical_type_without_srid)); out.extend_from_slice(&bytes[9..]); // skip the 4 SRID bytes Some(Cow::Owned(out)) } /// Inject or replace the SRID in a simple EWKB/WKB byte slice without parsing /// the geometry body. /// /// Returns `None` when the bytes are not recognisable as a simple Point, /// LineString, or Polygon EWKB/WKB header in either byte order. In those cases /// the caller should fall back to a full WKB→WKT→WKB round-trip, which /// normalises type codes and handles collection or ISO-dimensional input. fn try_set_srid_in_simple_wkb(bytes: &[u8], srid: i32) -> Option> { let header = try_read_simple_fast_path_header(bytes)?; let canonical_type_with_srid = header.canonical_type_without_srid | EWKB_SRID; if header.type_u32 & EWKB_SRID != 0 { // SRID already present — check if it already equals the requested SRID. let stored_srid = header.read_srid(bytes); if stored_srid == srid && header.type_u32 == canonical_type_with_srid { return Some(Cow::Borrowed(bytes)); } let mut out = bytes.to_vec(); out[1..5].copy_from_slice(&header.u32_bytes(canonical_type_with_srid)); out[5..9].copy_from_slice(&header.i32_bytes(srid)); Some(Cow::Owned(out)) } else { // No SRID yet — insert 4 bytes after the type code and set the flag. let mut out = Vec::with_capacity(bytes.len() + 4); out.push(header.byte_order); out.extend_from_slice(&header.u32_bytes(canonical_type_with_srid)); out.extend_from_slice(&header.i32_bytes(srid)); out.extend_from_slice(&bytes[5..]); Some(Cow::Owned(out)) } } #[cfg(test)] mod tests { use super::*; #[test] fn hex_nibble_lut_spot_check() { // Call build_hex_nibble_lut() at runtime so llvm-cov instruments it. let lut = build_hex_nibble_lut(); assert_eq!(lut[b'0' as usize], 0); assert_eq!(lut[b'9' as usize], 9); assert_eq!(lut[b'a' as usize], 10); assert_eq!(lut[b'f' as usize], 15); assert_eq!(lut[b'A' as usize], 10); assert_eq!(lut[b'F' as usize], 15); assert_eq!(lut[b'G' as usize], 0xFF); assert_eq!(lut[b' ' as usize], 0xFF); } } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/types.rs000066400000000000000000000043711521227137000261620ustar00rootroot00000000000000use crate::error::{Error, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GeomType { Point = 1, LineString = 2, Polygon = 3, MultiPoint = 4, MultiLineString = 5, MultiPolygon = 6, GeometryCollection = 7, } impl GeomType { pub(crate) fn from_u32(v: u32) -> Result { match v { 1 => Ok(GeomType::Point), 2 => Ok(GeomType::LineString), 3 => Ok(GeomType::Polygon), 4 => Ok(GeomType::MultiPoint), 5 => Ok(GeomType::MultiLineString), 6 => Ok(GeomType::MultiPolygon), 7 => Ok(GeomType::GeometryCollection), _ => Err(Error::UnsupportedGeometryType(v)), } } pub(crate) fn wkt_name(self) -> &'static str { match self { GeomType::Point => "POINT", GeomType::LineString => "LINESTRING", GeomType::Polygon => "POLYGON", GeomType::MultiPoint => "MULTIPOINT", GeomType::MultiLineString => "MULTILINESTRING", GeomType::MultiPolygon => "MULTIPOLYGON", GeomType::GeometryCollection => "GEOMETRYCOLLECTION", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Dimension { XY, XYZ, XYM, XYZM, } impl Dimension { /// Number of f64 values per coordinate tuple. pub fn coord_size(self) -> usize { match self { Dimension::XY => 2, Dimension::XYZ | Dimension::XYM => 3, Dimension::XYZM => 4, } } /// WKT dimension tag inserted between geometry type and coordinate list. pub(crate) fn wkt_tag(self) -> &'static str { match self { Dimension::XY => "", Dimension::XYZ => " Z", Dimension::XYM => " M", Dimension::XYZM => " ZM", } } /// EWKB flag bits OR-ed into the type code. pub(crate) fn ewkb_flags(self) -> u32 { match self { Dimension::XY => 0, Dimension::XYZ => EWKB_Z, Dimension::XYM => EWKB_M, Dimension::XYZM => EWKB_Z | EWKB_M, } } } // EWKB flag constants pub(crate) const EWKB_Z: u32 = 0x8000_0000; pub(crate) const EWKB_M: u32 = 0x4000_0000; pub(crate) const EWKB_SRID: u32 = 0x2000_0000; adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkb_to_wkt/000077500000000000000000000000001521227137000266155ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkb_to_wkt/builder.rs000066400000000000000000000020641521227137000306130ustar00rootroot00000000000000use std::fmt::Write as _; pub(super) struct WktBuilder { buf: String, } impl WktBuilder { pub fn with_capacity(n: usize) -> Self { Self { buf: String::with_capacity(n), } } pub fn finish(self) -> String { self.buf } pub fn push_str(&mut self, s: &str) { self.buf.push_str(s); } pub fn push_char(&mut self, c: char) { self.buf.push(c); } pub fn push_srid_prefix(&mut self, srid: i32) { write!(self.buf, "SRID={srid};").unwrap(); } /// Writes space-separated coordinate values. pub fn push_coord(&mut self, coords: &[f64]) { for (i, &v) in coords.iter().enumerate() { if i > 0 { self.buf.push(' '); } push_f64(&mut self.buf, v); } } } fn push_f64(buf: &mut String, v: f64) { if v == 0.0 { buf.push('0'); } else if v.is_finite() && v == v.trunc() && v.abs() < 1e15 { write!(buf, "{}", v as i64).unwrap(); } else { write!(buf, "{v}").unwrap(); } } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkb_to_wkt/mod.rs000066400000000000000000000013061521227137000277420ustar00rootroot00000000000000mod builder; mod reader; use crate::error::Result; pub(crate) fn convert(wkb: &[u8]) -> Result { let mut reader = reader::WkbReader::new(wkb); let mut builder = builder::WktBuilder::with_capacity(wkb.len().saturating_mul(3).min(16 << 20)); reader.read_geometry(&mut builder, true)?; reader.expect_eof()?; Ok(builder.finish()) } pub(crate) fn convert_split_srid(wkb: &[u8]) -> Result<(String, Option)> { let mut reader = reader::WkbReader::new(wkb); let mut builder = builder::WktBuilder::with_capacity(wkb.len().saturating_mul(3).min(16 << 20)); let srid = reader.read_geometry(&mut builder, false)?; reader.expect_eof()?; Ok((builder.finish(), srid)) } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkb_to_wkt/reader.rs000066400000000000000000000324111521227137000304260ustar00rootroot00000000000000use super::builder::WktBuilder; use crate::error::{Error, Result}; use crate::types::{Dimension, GeomType, EWKB_M, EWKB_SRID, EWKB_Z}; const MAX_GEOMETRY_DEPTH: usize = 128; #[derive(Debug, Clone, Copy)] struct GeometryHeader { dim: Dimension, srid: Option, } pub(super) struct WkbReader<'a> { data: &'a [u8], pos: usize, little_endian: bool, } impl<'a> WkbReader<'a> { pub fn new(data: &'a [u8]) -> Self { Self { data, pos: 0, little_endian: true, } } fn remaining(&self) -> usize { self.data.len() - self.pos } fn read_u8(&mut self) -> Result { if self.pos >= self.data.len() { return Err(Error::InvalidWkb("unexpected end of data".into())); } let v = self.data[self.pos]; self.pos += 1; Ok(v) } fn read_u32(&mut self) -> Result { if self.remaining() < 4 { return Err(Error::InvalidWkb( "unexpected end of data reading u32".into(), )); } let bytes: [u8; 4] = self.data[self.pos..self.pos + 4].try_into().unwrap(); self.pos += 4; Ok(if self.little_endian { u32::from_le_bytes(bytes) } else { u32::from_be_bytes(bytes) }) } fn read_f64(&mut self) -> Result { if self.remaining() < 8 { return Err(Error::InvalidWkb( "unexpected end of data reading f64".into(), )); } let bytes: [u8; 8] = self.data[self.pos..self.pos + 8].try_into().unwrap(); self.pos += 8; Ok(if self.little_endian { f64::from_le_bytes(bytes) } else { f64::from_be_bytes(bytes) }) } fn read_byte_order(&mut self) -> Result<()> { match self.read_u8()? { 0 => { self.little_endian = false; Ok(()) } 1 => { self.little_endian = true; Ok(()) } b => Err(Error::InvalidWkb(format!("invalid byte order marker: {b}"))), } } fn read_i32(&mut self) -> Result { if self.remaining() < 4 { return Err(Error::InvalidWkb( "unexpected end of data reading i32".into(), )); } let bytes: [u8; 4] = self.data[self.pos..self.pos + 4].try_into().unwrap(); self.pos += 4; Ok(if self.little_endian { i32::from_le_bytes(bytes) } else { i32::from_be_bytes(bytes) }) } /// Decodes the 4-byte type code, handling both ISO WKB and EWKB encodings. /// Returns (GeomType, Dimension, Option). fn read_type(&mut self) -> Result<(GeomType, Dimension, Option)> { let raw = self.read_u32()?; let has_z = raw & EWKB_Z != 0; let has_m = raw & EWKB_M != 0; let has_srid = raw & EWKB_SRID != 0; // Strip EWKB flag bits; the base type sits in the lower 16 bits let base = raw & 0x0000_FFFF; let (geom_code, dim) = if has_z || has_m { // EWKB dimension encoding via flag bits let dim = if has_z && has_m { Dimension::XYZM } else if has_z { Dimension::XYZ } else { Dimension::XYM }; (base, dim) } else if base > 3000 { // ISO WKB XYZM: type + 3000 (base - 3000, Dimension::XYZM) } else if base > 2000 { // ISO WKB XYM: type + 2000 (base - 2000, Dimension::XYM) } else if base > 1000 { // ISO WKB XYZ: type + 1000 (base - 1000, Dimension::XYZ) } else { (base, Dimension::XY) }; let geom_type = GeomType::from_u32(geom_code)?; let srid = if has_srid { let raw = self.read_i32()?; if raw > 0 { Some(raw) } else { None } } else { None }; Ok((geom_type, dim, srid)) } pub fn expect_eof(&self) -> Result<()> { if self.remaining() == 0 { Ok(()) } else { Err(Error::InvalidWkb(format!( "unexpected trailing data: {} bytes remaining", self.remaining() ))) } } /// Reads one complete WKB geometry and appends its WKT representation to `out`. /// Returns the top-level SRID if the geometry was EWKB-encoded with one. pub fn read_geometry( &mut self, out: &mut WktBuilder, include_srid_prefix: bool, ) -> Result> { self.read_geometry_at_depth(out, include_srid_prefix, 0) .map(|header| header.srid) } fn read_geometry_at_depth( &mut self, out: &mut WktBuilder, include_srid_prefix: bool, depth: usize, ) -> Result { if depth >= MAX_GEOMETRY_DEPTH { return Err(Error::InvalidWkb(format!( "geometry nesting exceeds maximum depth of {MAX_GEOMETRY_DEPTH}" ))); } self.read_byte_order()?; let (geom_type, dim, srid) = self.read_type()?; if include_srid_prefix { if let Some(srid) = srid { out.push_srid_prefix(srid); } } out.push_str(geom_type.wkt_name()); out.push_str(dim.wkt_tag()); match geom_type { GeomType::Point => self.read_point_body(out, dim)?, GeomType::LineString => self.read_linestring_body(out, dim)?, GeomType::Polygon => self.read_polygon_body(out, dim)?, GeomType::MultiPoint => self.read_multi_point_body(out, dim)?, GeomType::MultiLineString => self.read_multi_linestring_body(out, dim)?, GeomType::MultiPolygon => self.read_multi_polygon_body(out, dim)?, GeomType::GeometryCollection => self.read_collection_body(out, dim, depth)?, } Ok(GeometryHeader { dim, srid }) } fn read_point_body(&mut self, out: &mut WktBuilder, dim: Dimension) -> Result<()> { let n = dim.coord_size(); let mut coords = [0f64; 4]; for item in coords.iter_mut().take(n) { *item = self.read_f64()?; } let coords = &coords[..n]; if coords.iter().all(|v| v.is_nan()) { out.push_str(" EMPTY"); } else { out.push_str(" ("); out.push_coord(coords); out.push_char(')'); } Ok(()) } fn read_linestring_body(&mut self, out: &mut WktBuilder, dim: Dimension) -> Result<()> { let num_pts = self.read_u32()? as usize; if num_pts == 0 { out.push_str(" EMPTY"); return Ok(()); } out.push_str(" ("); self.read_coord_seq(out, dim, num_pts)?; out.push_char(')'); Ok(()) } fn read_polygon_body(&mut self, out: &mut WktBuilder, dim: Dimension) -> Result<()> { let num_rings = self.read_u32()? as usize; if num_rings == 0 { out.push_str(" EMPTY"); return Ok(()); } out.push_str(" ("); for i in 0..num_rings { if i > 0 { out.push_str(", "); } let num_pts = self.read_u32()? as usize; out.push_char('('); self.read_coord_seq(out, dim, num_pts)?; out.push_char(')'); } out.push_char(')'); Ok(()) } /// Each sub-geometry in a MULTI* has its own full WKB header but is rendered /// in WKT without a type keyword: `((x y), (x y))`. fn read_multi_point_body(&mut self, out: &mut WktBuilder, parent_dim: Dimension) -> Result<()> { let num_geoms = self.read_u32()? as usize; if num_geoms == 0 { out.push_str(" EMPTY"); return Ok(()); } out.push_str(" ("); for i in 0..num_geoms { if i > 0 { out.push_str(", "); } self.read_byte_order()?; let (geom_type, dim, _) = self.read_type()?; if geom_type != GeomType::Point { return Err(Error::InvalidWkb(format!( "expected Point sub-geometry in MultiPoint, got type code {geom_type:?}", ))); } validate_child_dimension(GeomType::MultiPoint, parent_dim, dim)?; let n = dim.coord_size(); let mut coords = [0f64; 4]; for item in coords.iter_mut().take(n) { *item = self.read_f64()?; } let coords = &coords[..n]; if coords.iter().all(|v| v.is_nan()) { out.push_str("EMPTY"); } else { out.push_char('('); out.push_coord(coords); out.push_char(')'); } } out.push_char(')'); Ok(()) } fn read_multi_linestring_body( &mut self, out: &mut WktBuilder, parent_dim: Dimension, ) -> Result<()> { let num_geoms = self.read_u32()? as usize; if num_geoms == 0 { out.push_str(" EMPTY"); return Ok(()); } out.push_str(" ("); for i in 0..num_geoms { if i > 0 { out.push_str(", "); } self.read_byte_order()?; let (geom_type, dim, _) = self.read_type()?; if geom_type != GeomType::LineString { return Err(Error::InvalidWkb(format!( "expected LineString sub-geometry in MultiLineString, got type code {geom_type:?}", ))); } validate_child_dimension(GeomType::MultiLineString, parent_dim, dim)?; let num_pts = self.read_u32()? as usize; if num_pts == 0 { out.push_str("EMPTY"); } else { out.push_char('('); self.read_coord_seq(out, dim, num_pts)?; out.push_char(')'); } } out.push_char(')'); Ok(()) } fn read_multi_polygon_body( &mut self, out: &mut WktBuilder, parent_dim: Dimension, ) -> Result<()> { let num_geoms = self.read_u32()? as usize; if num_geoms == 0 { out.push_str(" EMPTY"); return Ok(()); } out.push_str(" ("); for i in 0..num_geoms { if i > 0 { out.push_str(", "); } self.read_byte_order()?; let (geom_type, dim, _) = self.read_type()?; if geom_type != GeomType::Polygon { return Err(Error::InvalidWkb(format!( "expected Polygon sub-geometry in MultiPolygon, got type code {geom_type:?}", ))); } validate_child_dimension(GeomType::MultiPolygon, parent_dim, dim)?; let num_rings = self.read_u32()? as usize; if num_rings == 0 { out.push_str("EMPTY"); } else { out.push_char('('); for j in 0..num_rings { if j > 0 { out.push_str(", "); } let num_pts = self.read_u32()? as usize; out.push_char('('); self.read_coord_seq(out, dim, num_pts)?; out.push_char(')'); } out.push_char(')'); } } out.push_char(')'); Ok(()) } /// GeometryCollection members are full WKT geometries with type keywords. fn read_collection_body( &mut self, out: &mut WktBuilder, parent_dim: Dimension, depth: usize, ) -> Result<()> { let num_geoms = self.read_u32()? as usize; if num_geoms == 0 { out.push_str(" EMPTY"); return Ok(()); } out.push_str(" ("); for i in 0..num_geoms { if i > 0 { out.push_str(", "); } let child = self.read_geometry_at_depth(out, false, depth + 1)?; if parent_dim != Dimension::XY && child.dim != parent_dim { return Err(Error::InvalidWkb(format!( "GeometryCollection {parent_dim:?} member has incompatible dimension {:?}", child.dim ))); } } out.push_char(')'); Ok(()) } fn read_coord_seq( &mut self, out: &mut WktBuilder, dim: Dimension, num_pts: usize, ) -> Result<()> { let n = dim.coord_size(); let mut coords = [0f64; 4]; for i in 0..num_pts { if i > 0 { out.push_str(", "); } for item in coords.iter_mut().take(n) { *item = self.read_f64()?; } out.push_coord(&coords[..n]); } Ok(()) } } fn validate_child_dimension( parent_type: GeomType, parent_dim: Dimension, child_dim: Dimension, ) -> Result<()> { if child_dim == parent_dim { Ok(()) } else { Err(Error::InvalidWkb(format!( "{parent_type:?} child has incompatible dimension {child_dim:?}; expected {parent_dim:?}" ))) } } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkt_to_wkb/000077500000000000000000000000001521227137000266155ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkt_to_wkb/mod.rs000066400000000000000000000313641521227137000277510ustar00rootroot00000000000000mod tokenizer; mod writer; use crate::error::{Error, Result}; use crate::types::{Dimension, GeomType}; use tokenizer::{EmptyOrLparen, Tokenizer}; use writer::{HexWkbWriter, WkbWrite, WkbWriter}; const MAX_INITIAL_WKB_CAPACITY: usize = 16 << 20; const MAX_GEOMETRY_DEPTH: usize = 128; /// Converts WKT/EWKT to EWKB, embedding the SRID (if any) in the output. pub(crate) fn convert(wkt: &str) -> Result> { let mut tok = Tokenizer::new(wkt); let srid = tok.read_srid_prefix()?; convert_tokenized(tok, wkt.len(), srid) } /// Converts WKT/EWKT to EWKB, embedding `srid` in the top-level output header. /// /// Any input `SRID=` prefix is still parsed and validated, but its value is /// ignored in favour of the forced output SRID. pub(crate) fn convert_with_forced_srid(wkt: &str, srid: i32) -> Result> { let mut tok = Tokenizer::new(wkt); tok.read_srid_prefix()?; convert_tokenized(tok, wkt.len(), Some(srid)) } pub(crate) fn convert_to_hex(wkt: &str) -> Result { let mut tok = Tokenizer::new(wkt); let srid = tok.read_srid_prefix()?; convert_tokenized_to_hex(tok, wkt.len(), srid) } pub(crate) fn convert_to_hex_with_forced_srid(wkt: &str, srid: i32) -> Result { let mut tok = Tokenizer::new(wkt); tok.read_srid_prefix()?; convert_tokenized_to_hex(tok, wkt.len(), Some(srid)) } pub(crate) fn convert_to_hex_split_srid(wkt: &str) -> Result { let mut tok = Tokenizer::new(wkt); tok.read_srid_prefix()?; convert_tokenized_to_hex(tok, wkt.len(), None) } fn convert_tokenized(mut tok: Tokenizer<'_>, wkt_len: usize, srid: Option) -> Result> { let mut writer = WkbWriter::with_capacity(initial_wkb_capacity(wkt_len)); parse_geometry_at_depth(&mut tok, &mut writer, srid, 0)?; tok.expect_eof()?; Ok(writer.into_bytes()) } fn convert_tokenized_to_hex( mut tok: Tokenizer<'_>, wkt_len: usize, srid: Option, ) -> Result { let mut writer = HexWkbWriter::with_capacity(initial_wkb_capacity(wkt_len)); parse_geometry_at_depth(&mut tok, &mut writer, srid, 0)?; tok.expect_eof()?; Ok(writer.into_string()) } /// Converts WKT/EWKT to EWKB, returning the SRID separately (not embedded in bytes). pub(crate) fn convert_split_srid(wkt: &str) -> Result<(Vec, Option)> { let mut tok = Tokenizer::new(wkt); let srid = tok.read_srid_prefix()?; let mut writer = WkbWriter::with_capacity(initial_wkb_capacity(wkt.len())); parse_geometry_at_depth(&mut tok, &mut writer, None, 0)?; tok.expect_eof()?; Ok((writer.into_bytes(), srid)) } fn initial_wkb_capacity(wkt_len: usize) -> usize { wkt_len.min(MAX_INITIAL_WKB_CAPACITY) } fn parse_geometry_at_depth( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, srid: Option, depth: usize, ) -> Result<(GeomType, Dimension)> { if depth >= MAX_GEOMETRY_DEPTH { return Err(Error::InvalidWkt(format!( "geometry nesting exceeds maximum depth of {MAX_GEOMETRY_DEPTH}" ))); } let (geom_type, dim) = tok.read_type_and_dim()?; match geom_type { GeomType::Point => parse_point(tok, writer, dim, srid)?, GeomType::LineString => parse_linestring(tok, writer, dim, srid)?, GeomType::Polygon => parse_polygon(tok, writer, dim, srid)?, GeomType::MultiPoint => parse_multi_point(tok, writer, dim, srid)?, GeomType::MultiLineString => parse_multi_linestring(tok, writer, dim, srid)?, GeomType::MultiPolygon => parse_multi_polygon(tok, writer, dim, srid)?, GeomType::GeometryCollection => parse_collection(tok, writer, dim, srid, depth)?, }; Ok((geom_type, dim)) } fn parse_point( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, srid: Option, ) -> Result<()> { writer.write_header(GeomType::Point, dim, srid); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => { // POINT EMPTY: PostGIS convention — NaN for all coordinates write_empty_point_body(writer, dim); return Ok(()); } EmptyOrLparen::Lparen => {} } write_coord_tuple(tok, writer, dim)?; tok.expect_rparen()?; Ok(()) } fn parse_linestring( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, srid: Option, ) -> Result<()> { writer.write_header(GeomType::LineString, dim, srid); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => { writer.write_u32(0); return Ok(()); } EmptyOrLparen::Lparen => {} } let pts_pos = writer.reserve_u32(); let pts = read_coord_seq(tok, writer, dim)?; writer.patch_u32(pts_pos, pts); Ok(()) } fn parse_polygon( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, srid: Option, ) -> Result<()> { writer.write_header(GeomType::Polygon, dim, srid); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => { writer.write_u32(0); return Ok(()); } EmptyOrLparen::Lparen => {} } let rings_pos = writer.reserve_u32(); let rings = read_rings(tok, writer, dim)?; writer.patch_u32(rings_pos, rings); Ok(()) } fn parse_multi_point( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, srid: Option, ) -> Result<()> { writer.write_header(GeomType::MultiPoint, dim, srid); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => { writer.write_u32(0); return Ok(()); } EmptyOrLparen::Lparen => {} } let count_pos = writer.reserve_u32(); let mut count = 0u32; loop { writer.write_header(GeomType::Point, dim, None); match tok.try_read_empty_or_lparen()? { Some(EmptyOrLparen::Empty) => write_empty_point_body(writer, dim), Some(EmptyOrLparen::Lparen) => { write_coord_tuple(tok, writer, dim)?; tok.expect_rparen()?; } None => write_coord_tuple(tok, writer, dim)?, } increment_count(&mut count, "MultiPoint members")?; if !tok.read_comma_or_rparen()? { break; } } writer.patch_u32(count_pos, count); Ok(()) } fn parse_multi_linestring( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, srid: Option, ) -> Result<()> { writer.write_header(GeomType::MultiLineString, dim, srid); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => { writer.write_u32(0); return Ok(()); } EmptyOrLparen::Lparen => {} } let count_pos = writer.reserve_u32(); let mut count = 0u32; loop { writer.write_header(GeomType::LineString, dim, None); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => writer.write_u32(0), EmptyOrLparen::Lparen => { let pts_pos = writer.reserve_u32(); let pts = read_coord_seq(tok, writer, dim)?; writer.patch_u32(pts_pos, pts); } } increment_count(&mut count, "MultiLineString members")?; if !tok.read_comma_or_rparen()? { break; } } writer.patch_u32(count_pos, count); Ok(()) } fn parse_multi_polygon( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, srid: Option, ) -> Result<()> { writer.write_header(GeomType::MultiPolygon, dim, srid); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => { writer.write_u32(0); return Ok(()); } EmptyOrLparen::Lparen => {} } let count_pos = writer.reserve_u32(); let mut count = 0u32; loop { writer.write_header(GeomType::Polygon, dim, None); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => writer.write_u32(0), EmptyOrLparen::Lparen => { let rings_pos = writer.reserve_u32(); let rings = read_rings(tok, writer, dim)?; writer.patch_u32(rings_pos, rings); } } increment_count(&mut count, "MultiPolygon members")?; if !tok.read_comma_or_rparen()? { break; } } writer.patch_u32(count_pos, count); Ok(()) } fn parse_collection( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, srid: Option, depth: usize, ) -> Result<()> { writer.write_header(GeomType::GeometryCollection, dim, srid); match tok.read_empty_or_lparen()? { EmptyOrLparen::Empty => { writer.write_u32(0); return Ok(()); } EmptyOrLparen::Lparen => {} } let count_pos = writer.reserve_u32(); let mut count = 0u32; loop { // Members of a GeometryCollection are full WKT geometries with type keywords. let (_, child_dim) = parse_geometry_at_depth(tok, writer, None, depth + 1)?; if dim != Dimension::XY && child_dim != dim { return Err(Error::InvalidWkt(format!( "GeometryCollection {dim:?} member has incompatible dimension {child_dim:?}" ))); } increment_count(&mut count, "GeometryCollection members")?; if !tok.read_comma_or_rparen()? { break; } } writer.patch_u32(count_pos, count); Ok(()) } /// Reads comma-separated coordinate tuples until the closing `)` is consumed. /// Returns the number of coordinate tuples read. /// /// Convention: the opening `(` has already been consumed by the caller. /// This function consumes the matching closing `)`. fn read_coord_seq( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, ) -> Result { let mut count = 0u32; loop { write_coord_tuple(tok, writer, dim)?; increment_count(&mut count, "coordinate tuples")?; if !tok.read_comma_or_rparen()? { break; } } Ok(count) } /// Reads the ring list of a Polygon body: `(ring), (ring), ...` until the closing `)`. /// Returns the number of rings. /// /// Convention: the outer polygon `(` has already been consumed by the caller. /// This function consumes the matching closing `)`. fn read_rings(tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension) -> Result { let mut rings = 0u32; loop { tok.expect_lparen()?; let pts_pos = writer.reserve_u32(); let pts = read_coord_seq(tok, writer, dim)?; writer.patch_u32(pts_pos, pts); increment_count(&mut rings, "polygon rings")?; if !tok.read_comma_or_rparen()? { break; } } Ok(rings) } fn write_coord_tuple( tok: &mut Tokenizer<'_>, writer: &mut impl WkbWrite, dim: Dimension, ) -> Result<()> { for _ in 0..dim.coord_size() { writer.write_f64(tok.read_number()?); } Ok(()) } fn write_empty_point_body(writer: &mut impl WkbWrite, dim: Dimension) { for _ in 0..dim.coord_size() { writer.write_f64(f64::NAN); } } fn increment_count(count: &mut u32, what: &str) -> Result<()> { *count = count .checked_add(1) .ok_or_else(|| Error::InvalidWkt(format!("too many {what}")))?; Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn initial_wkb_capacity_uses_capped_input_len() { assert_eq!(initial_wkb_capacity(0), 0); assert_eq!(initial_wkb_capacity(40), 40); assert_eq!( initial_wkb_capacity(MAX_INITIAL_WKB_CAPACITY + 1), MAX_INITIAL_WKB_CAPACITY ); } #[test] fn forced_srid_overrides_validated_input_srid_without_second_copy() { let wkb = convert_with_forced_srid("SRID=4326;POINT (1 2)", 3857).unwrap(); assert_eq!( crate::wkb_to_wkt::convert(&wkb).unwrap(), "SRID=3857;POINT (1 2)" ); } #[test] fn forced_srid_is_only_written_to_top_level_collection_header() { let wkb = convert_with_forced_srid("GEOMETRYCOLLECTION (POINT (1 2))", 3857).unwrap(); assert_eq!( u32::from_le_bytes(wkb[1..5].try_into().unwrap()), 0x2000_0007 ); assert_eq!(u32::from_le_bytes(wkb[5..9].try_into().unwrap()), 3857); assert_eq!(u32::from_le_bytes(wkb[14..18].try_into().unwrap()), 1); } #[test] fn forced_srid_still_validates_input_srid_prefix() { assert!(convert_with_forced_srid("SRID=abc;POINT (1 2)", 3857).is_err()); } #[test] fn increment_count_checks_overflow() { let mut count = 0; increment_count(&mut count, "test items").unwrap(); assert_eq!(count, 1); let mut count = u32::MAX; assert!(increment_count(&mut count, "test items").is_err()); assert_eq!(count, u32::MAX); } } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkt_to_wkb/tokenizer.rs000066400000000000000000000237201521227137000312010ustar00rootroot00000000000000use crate::error::{Error, Result}; use crate::types::{Dimension, GeomType}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum EmptyOrLparen { Empty, Lparen, } pub(super) struct Tokenizer<'a> { input: &'a str, pos: usize, } impl<'a> Tokenizer<'a> { pub fn new(input: &'a str) -> Self { Self { input, pos: 0 } } fn rest(&self) -> &str { &self.input[self.pos..] } fn skip_whitespace(&mut self) { while let Some(&b) = self.input.as_bytes().get(self.pos) { if b.is_ascii_whitespace() { self.pos += 1; } else if b.is_ascii() { break; } else { let rest = self.rest(); self.pos += rest.len() - rest.trim_start().len(); break; } } } fn peek_byte(&self) -> Option { self.input.as_bytes().get(self.pos).copied() } fn consume_byte(&mut self, b: u8) -> bool { if self.peek_byte() == Some(b) { self.pos += 1; true } else { false } } /// Reads `SRID=;` prefix if present. Must be called before any other method. pub fn read_srid_prefix(&mut self) -> Result> { self.skip_whitespace(); // Use a case-insensitive prefix check on the first 5 ASCII bytes. // `get` keeps malformed non-ASCII prefixes on the normal error path. if self .rest() .get(..5) .is_some_and(|p| p.eq_ignore_ascii_case("SRID=")) { self.pos += 5; let srid = self.read_srid_int()?; self.skip_whitespace(); if self.consume_byte(b';') { Ok(if srid > 0 { Some(srid) } else { None }) } else { Err(Error::InvalidWkt("expected ';' after SRID value".into())) } } else { Ok(None) } } fn read_srid_int(&mut self) -> Result { let bytes = self.input.as_bytes(); let start = self.pos; if bytes.get(self.pos) == Some(&b'-') { self.pos += 1; } let digits_start = self.pos; while bytes.get(self.pos).is_some_and(u8::is_ascii_digit) { self.pos += 1; } if self.pos == digits_start { return Err(Error::InvalidWkt(format!( "expected integer at position {digits_start}" ))); } let s = &self.input[start..self.pos]; s.parse::() .map_err(|_| Error::InvalidWkt(format!("integer out of i32 range: {s}"))) } /// Reads the geometry type keyword and an optional dimension tag (Z / M / ZM). pub fn read_type_and_dim(&mut self) -> Result<(GeomType, Dimension)> { self.skip_whitespace(); let rest = self.rest(); let first = rest .as_bytes() .first() .copied() .map(|b| b.to_ascii_uppercase()); let (geom_type, keyword_len) = match first { Some(b'G') if matches_word(rest, "GEOMETRYCOLLECTION") => { (GeomType::GeometryCollection, 18) } Some(b'M') if matches_word(rest, "MULTILINESTRING") => (GeomType::MultiLineString, 15), Some(b'M') if matches_word(rest, "MULTIPOLYGON") => (GeomType::MultiPolygon, 12), Some(b'M') if matches_word(rest, "MULTIPOINT") => (GeomType::MultiPoint, 10), Some(b'L') if matches_word(rest, "LINESTRING") => (GeomType::LineString, 10), Some(b'P') if matches_word(rest, "POLYGON") => (GeomType::Polygon, 7), Some(b'P') if matches_word(rest, "POINT") => (GeomType::Point, 5), _ => { return Err(Error::InvalidWkt(format!( "unknown geometry type at position {}: {:?}", self.pos, snippet(rest, 20) ))); } }; self.pos += keyword_len; let dim = self.read_dim_tag()?; Ok((geom_type, dim)) } fn read_dim_tag(&mut self) -> Result { let saved = self.pos; // Dimension tag must be preceded by whitespace to avoid misreading // "POLYGON(...)" as "POLY" + dim "GON". let rest_before_skip = self.rest(); if !rest_before_skip .as_bytes() .first() .is_some_and(u8::is_ascii_whitespace) { return Ok(Dimension::XY); } self.skip_whitespace(); let rest = self.rest(); let bytes = rest.as_bytes(); let b0 = bytes.first().copied().map(|b| b.to_ascii_uppercase()); let b1 = bytes.get(1).copied().map(|b| b.to_ascii_uppercase()); let (dim, len) = if b0 == Some(b'Z') && b1 == Some(b'M') && word_ends_at(rest, 2) { (Dimension::XYZM, 2) } else if b0 == Some(b'Z') && word_ends_at(rest, 1) { (Dimension::XYZ, 1) } else if b0 == Some(b'M') && word_ends_at(rest, 1) { (Dimension::XYM, 1) } else { // No dim tag — restore position (was whitespace that belongs to the caller) self.pos = saved; return Ok(Dimension::XY); }; self.pos += len; Ok(dim) } /// Reads a floating-point number, skipping leading whitespace. pub fn read_number(&mut self) -> Result { self.skip_whitespace(); let start = self.pos; let bytes = self.input.as_bytes(); // Consume sign, digits, decimal point, and exponent. let mut end = start; while bytes .get(end) .is_some_and(|&b| matches!(b, b'0'..=b'9' | b'.' | b'e' | b'E' | b'+' | b'-')) { end += 1; } if end == start { let rest = self.rest(); return Err(Error::InvalidWkt(format!( "expected number at position {}, got {:?}", self.pos, snippet(rest, 10) ))); } let s = &self.input[start..end]; let v = s.parse::().map_err(|_| { Error::InvalidWkt(format!("invalid number {:?} at position {}", s, self.pos)) })?; if !v.is_finite() { return Err(Error::InvalidWkt(format!( "non-finite coordinate {:?} at position {}", s, self.pos ))); } self.pos = end; Ok(v) } pub fn expect_lparen(&mut self) -> Result<()> { self.skip_whitespace(); if self.consume_byte(b'(') { Ok(()) } else { Err(Error::InvalidWkt(format!( "expected '(' at position {}, got {:?}", self.pos, self.rest().chars().next() ))) } } pub fn expect_rparen(&mut self) -> Result<()> { self.skip_whitespace(); if self.consume_byte(b')') { Ok(()) } else { Err(Error::InvalidWkt(format!( "expected ')' at position {}, got {:?}", self.pos, self.rest().chars().next() ))) } } /// Reads the next non-whitespace token expecting `,` or `)`. /// Returns `true` for `,` (more items follow), `false` for `)` (list is done). /// The consumed token is not put back. pub fn read_comma_or_rparen(&mut self) -> Result { self.skip_whitespace(); match self.peek_byte() { Some(b',') => { self.pos += 1; Ok(true) } Some(b')') => { self.pos += 1; Ok(false) } _ => Err(Error::InvalidWkt(format!( "expected ',' or ')' at position {}, got {:?}", self.pos, self.rest().chars().next() ))), } } pub fn try_read_empty_or_lparen(&mut self) -> Result> { self.skip_whitespace(); let rest = self.rest(); if rest .get(..5) .is_some_and(|s| s.eq_ignore_ascii_case("EMPTY")) { if word_ends_at(rest, 5) { self.pos += 5; return Ok(Some(EmptyOrLparen::Empty)); } return Err(Error::InvalidWkt(format!( "expected delimiter after EMPTY at position {}", self.pos + 5 ))); } if self.consume_byte(b'(') { Ok(Some(EmptyOrLparen::Lparen)) } else { Ok(None) } } /// Reads the next non-whitespace content: /// - If it is `EMPTY`, consumes it and returns `Empty`. /// - If it starts with `(`, consumes it and returns `Lparen`. /// - Otherwise, returns an error. pub fn read_empty_or_lparen(&mut self) -> Result { self.try_read_empty_or_lparen()?.ok_or_else(|| { Error::InvalidWkt(format!( "expected 'EMPTY' or '(' at position {}, got {:?}", self.pos, snippet(self.rest(), 10) )) }) } pub fn expect_eof(&mut self) -> Result<()> { self.skip_whitespace(); if self.rest().is_empty() { Ok(()) } else { Err(Error::InvalidWkt(format!( "unexpected trailing content at position {}: {:?}", self.pos, snippet(self.rest(), 20) ))) } } } fn matches_word(s: &str, word: &str) -> bool { s.get(..word.len()) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(word)) && word_ends_at(s, word.len()) } fn word_ends_at(s: &str, offset: usize) -> bool { match s.as_bytes().get(offset).copied() { None => true, Some(b) if b.is_ascii() => !b.is_ascii_alphabetic(), Some(_) => s .get(offset..) .and_then(|tail| tail.chars().next()) .is_none_or(|c| !c.is_alphabetic()), } } fn snippet(s: &str, max_chars: usize) -> String { s.chars().take(max_chars).collect() } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/src/wkt_to_wkb/writer.rs000066400000000000000000000057671521227137000305160ustar00rootroot00000000000000use crate::types::{Dimension, GeomType, EWKB_SRID}; const HEX: &[u8; 16] = b"0123456789ABCDEF"; pub(super) trait WkbWrite { fn write_u8(&mut self, v: u8); fn write_u32(&mut self, v: u32); fn write_f64(&mut self, v: f64); fn reserve_u32(&mut self) -> usize; fn patch_u32(&mut self, pos: usize, v: u32); /// Writes the EWKB geometry header: /// - byte order byte (always 0x01 = little-endian) /// - type code (geometry type | dimension flags | SRID flag) /// - SRID value (if srid is Some) fn write_header(&mut self, geom_type: GeomType, dim: Dimension, srid: Option) { self.write_u8(1); // little-endian let type_code = geom_type as u32 | dim.ewkb_flags() | srid.map_or(0, |_| EWKB_SRID); self.write_u32(type_code); if let Some(s) = srid { self.write_u32(s as u32); } } } pub(super) struct WkbWriter { buf: Vec, } impl WkbWriter { pub fn with_capacity(n: usize) -> Self { Self { buf: Vec::with_capacity(n), } } pub fn into_bytes(self) -> Vec { self.buf } } impl WkbWrite for WkbWriter { fn write_u8(&mut self, v: u8) { self.buf.push(v); } fn write_u32(&mut self, v: u32) { self.buf.extend_from_slice(&v.to_le_bytes()); } fn write_f64(&mut self, v: f64) { self.buf.extend_from_slice(&v.to_le_bytes()); } fn reserve_u32(&mut self) -> usize { let pos = self.buf.len(); self.buf.extend_from_slice(&[0u8; 4]); pos } fn patch_u32(&mut self, pos: usize, v: u32) { self.buf[pos..pos + 4].copy_from_slice(&v.to_le_bytes()); } } pub(super) struct HexWkbWriter { buf: Vec, } impl HexWkbWriter { pub fn with_capacity(n: usize) -> Self { Self { buf: Vec::with_capacity(n.saturating_mul(2)), } } pub fn into_string(self) -> String { // SAFETY: every byte is written by `write_hex_byte`, which only emits // uppercase ASCII hex digits. unsafe { String::from_utf8_unchecked(self.buf) } } fn write_hex_byte(&mut self, v: u8) { self.buf.push(HEX[(v >> 4) as usize]); self.buf.push(HEX[(v & 0xF) as usize]); } } impl WkbWrite for HexWkbWriter { fn write_u8(&mut self, v: u8) { self.write_hex_byte(v); } fn write_u32(&mut self, v: u32) { for b in v.to_le_bytes() { self.write_hex_byte(b); } } fn write_f64(&mut self, v: f64) { for b in v.to_le_bytes() { self.write_hex_byte(b); } } fn reserve_u32(&mut self) -> usize { let pos = self.buf.len() / 2; self.buf.extend_from_slice(b"00000000"); pos } fn patch_u32(&mut self, pos: usize, v: u32) { let mut hex_pos = pos * 2; for b in v.to_le_bytes() { self.buf[hex_pos] = HEX[(b >> 4) as usize]; self.buf[hex_pos + 1] = HEX[(b & 0xF) as usize]; hex_pos += 2; } } } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/tests/000077500000000000000000000000001521227137000250165ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/tests/ewkb_header_helpers.rs000066400000000000000000000356111521227137000313540ustar00rootroot00000000000000/// Tests for wkb_header_srid / wkb_strip_srid / wkb_set_srid / writers. use wkb_wkt_converter::{ wkb_header_srid, wkb_header_srid_including_unknown, wkb_set_srid, wkb_set_srid_writer, wkb_strip_srid, wkb_strip_srid_writer, wkb_to_wkt, wkt_to_wkb, SridMode, WkbWriter, }; // ── shared test helpers ─────────────────────────────────────────────────────── fn wkb(wkt: &str) -> Vec { wkt_to_wkb(wkt, SridMode::Auto).unwrap() } // Decode a hex string to bytes for constructing hand-crafted WKB fixtures. fn from_hex(h: &str) -> Vec { (0..h.len()) .step_by(2) .map(|i| u8::from_str_radix(&h[i..i + 2], 16).unwrap()) .collect() } fn wkt_of(bytes: &[u8]) -> String { wkb_to_wkt(bytes, SridMode::Auto).unwrap() } // Big-endian EWKB for SRID=4326;POINT (1 2): // 0x00 byte-order = BE // 0x20000001 type = POINT | EWKB_SRID (BE) // 0x000010E6 SRID = 4326 (BE i32) // 0x3FF0… X = 1.0 (BE f64) // 0x4000… Y = 2.0 (BE f64) const BE_SRID_POINT_HEX: &str = "0020000001000010E63FF00000000000004000000000000000"; const BE_SRID3_POINT_HEX: &str = "0020000001000000033FF00000000000004000000000000000"; const BE_SRID4_POINT_HEX: &str = "0020000001000000043FF00000000000004000000000000000"; // Big-endian WKB for POINT (1 2) — no SRID. const BE_POINT_HEX: &str = "00000000013FF00000000000004000000000000000"; const LE_SRID_ZERO_POINT_HEX: &str = "010100002000000000000000000000F03F0000000000000040"; const LE_SRID_MINUS_ONE_POINT_HEX: &str = "0101000020FFFFFFFF000000000000F03F0000000000000040"; fn make_iso_point_z() -> Vec { let mut wkb = vec![0x01u8]; // LE wkb.extend_from_slice(&1001u32.to_le_bytes()); // POINT Z wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); wkb.extend_from_slice(&3.0f64.to_le_bytes()); wkb } // ── wkb_header_srid ─────────────────────────────────────────────────────────── #[test] fn header_srid_le_ewkb_with_srid() { let bytes = wkb("SRID=4326;POINT (1 2)"); assert_eq!(wkb_header_srid(&bytes).unwrap(), Some(4326)); } #[test] fn header_srid_including_unknown_positive_srid_still_returns_srid() { let bytes = wkb("SRID=4326;POINT (1 2)"); assert_eq!( wkb_header_srid_including_unknown(&bytes).unwrap(), Some(4326) ); } #[test] fn header_srid_le_ewkb_without_srid() { let bytes = wkb("POINT (1 2)"); assert_eq!(wkb_header_srid(&bytes).unwrap(), None); } #[test] fn header_srid_le_ewkb_z_with_srid() { let bytes = wkb("SRID=4326;POINT Z (1 2 3)"); assert_eq!(wkb_header_srid(&bytes).unwrap(), Some(4326)); } #[test] fn header_srid_le_ewkb_linestring_with_srid() { let bytes = wkb("SRID=4326;LINESTRING (0 0, 1 1)"); assert_eq!(wkb_header_srid(&bytes).unwrap(), Some(4326)); } #[test] fn header_srid_le_ewkb_multipolygon_with_srid() { let bytes = wkb("SRID=4326;MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); assert_eq!(wkb_header_srid(&bytes).unwrap(), Some(4326)); } #[test] fn header_srid_le_ewkb_geometry_collection_with_srid() { let bytes = wkb("SRID=4326;GEOMETRYCOLLECTION (POINT (1 2))"); assert_eq!(wkb_header_srid(&bytes).unwrap(), Some(4326)); } #[test] fn header_srid_big_endian_with_srid_fast_path() { let bytes = from_hex(BE_SRID_POINT_HEX); // big-endian EWKB is handled by the fast path (types 1–7, known flags) assert_eq!(wkb_header_srid(&bytes).unwrap(), Some(4326)); } #[test] fn header_srid_big_endian_without_srid() { let bytes = from_hex(BE_POINT_HEX); assert_eq!(wkb_header_srid(&bytes).unwrap(), None); } #[test] fn header_srid_iso_point_z_returns_none() { // ISO WKB has no SRID flag set (high bits are all zero), so the fast // path returns None immediately without a full parse. let bytes = make_iso_point_z(); assert_eq!(wkb_header_srid(&bytes).unwrap(), None); } #[test] fn header_srid_negative_srid_in_wkt_input_is_normalized_away() { let bytes = wkb("SRID=-1;POINT (1 2)"); // SRID=-1 is normalised to "no SRID" by wkt_to_wkb, so the embedded // SRID flag is cleared — we get None. assert_eq!(wkb_header_srid(&bytes).unwrap(), None); } #[test] fn header_srid_raw_zero_is_treated_as_unknown() { let bytes = from_hex(LE_SRID_ZERO_POINT_HEX); assert_eq!(wkb_header_srid(&bytes).unwrap(), None); } #[test] fn header_srid_including_unknown_raw_zero_preserves_zero() { let bytes = from_hex(LE_SRID_ZERO_POINT_HEX); assert_eq!(wkb_header_srid_including_unknown(&bytes).unwrap(), Some(0)); } #[test] fn header_srid_raw_negative_one_is_treated_as_unknown() { let bytes = from_hex(LE_SRID_MINUS_ONE_POINT_HEX); assert_eq!(wkb_header_srid(&bytes).unwrap(), None); } #[test] fn header_srid_including_unknown_raw_negative_one_preserves_signed_value() { let bytes = from_hex(LE_SRID_MINUS_ONE_POINT_HEX); assert_eq!(wkb_header_srid_including_unknown(&bytes).unwrap(), Some(-1)); } #[test] fn header_srid_including_unknown_without_srid_still_returns_none() { let bytes = wkb("POINT (1 2)"); assert_eq!(wkb_header_srid_including_unknown(&bytes).unwrap(), None); } #[test] fn header_srid_truncated_srid_field_errors() { // 5-byte slice that has the SRID flag set but no room for the SRID value. // Type word 0x20000003 = Polygon | EWKB_SRID in LE. let bytes = from_hex("0103000020"); assert!(wkb_header_srid(&bytes).is_err()); } #[test] fn header_srid_including_unknown_truncated_srid_field_errors() { let bytes = from_hex("0103000020"); assert!(wkb_header_srid_including_unknown(&bytes).is_err()); } #[test] fn header_srid_empty_bytes_errors() { assert!(wkb_header_srid(&[]).is_err()); } #[test] fn header_srid_invalid_byte_order_errors() { assert!(wkb_header_srid(&[0x99]).is_err()); } #[test] fn header_srid_invalid_byte_order_full_header_falls_back_and_errors() { assert!(wkb_header_srid(&[0x99, 0x01, 0x00, 0x00, 0x00]).is_err()); } #[test] fn header_srid_unknown_high_bits_falls_back_and_preserves_result() { let mut bytes = vec![0x01u8]; bytes.extend_from_slice(&0x1000_0001u32.to_le_bytes()); bytes.extend_from_slice(&1.0f64.to_le_bytes()); bytes.extend_from_slice(&2.0f64.to_le_bytes()); assert_eq!(wkb_header_srid(&bytes).unwrap(), None); } // ── wkb_strip_srid ──────────────────────────────────────────────────────────── #[test] fn strip_srid_le_ewkb_removes_srid() { let bytes = wkb("SRID=4326;POINT (1 2)"); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!(wkt_of(&stripped), "POINT (1 2)"); } #[test] fn strip_srid_le_ewkb_no_srid_unchanged() { let bytes = wkb("POINT (1 2)"); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!(wkt_of(&stripped), "POINT (1 2)"); } #[test] fn strip_srid_le_ewkb_linestring() { let bytes = wkb("SRID=4326;LINESTRING (0 0, 1 1)"); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!(wkt_of(&stripped), "LINESTRING (0 0, 1 1)"); } #[test] fn strip_srid_big_endian_preserves_header_and_payload() { let bytes = from_hex(BE_SRID_POINT_HEX); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!(stripped, from_hex(BE_POINT_HEX)); } #[test] fn strip_srid_multipolygon_falls_back_and_preserves_geometry() { let bytes = wkb("SRID=4326;MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!(wkt_of(&stripped), "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); } #[test] fn strip_srid_geometry_collection_with_nested_geometries() { let bytes = wkb("SRID=4326;GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))"); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!( wkt_of(&stripped), "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))" ); } #[test] fn strip_srid_iso_point_z_normalises_no_srid() { let bytes = make_iso_point_z(); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!(wkt_of(&stripped), "POINT Z (1 2 3)"); } #[test] fn strip_srid_empty_bytes_errors() { assert!(wkb_strip_srid(&[]).is_err()); } // ── wkb_set_srid ───────────────────────────────────────────────────────────── #[test] fn set_srid_adds_srid_to_plain_wkb() { let bytes = wkb("POINT (1 2)"); let ewkb = wkb_set_srid(&bytes, 4326).unwrap(); assert_eq!(wkt_of(&ewkb), "SRID=4326;POINT (1 2)"); } #[test] fn set_srid_replaces_existing_srid() { let bytes = wkb("SRID=4326;POINT (1 2)"); let ewkb = wkb_set_srid(&bytes, 3857).unwrap(); assert_eq!(wkt_of(&ewkb), "SRID=3857;POINT (1 2)"); } #[test] fn set_srid_noop_when_srid_already_matches() { let bytes = wkb("SRID=4326;POINT (1 2)"); let ewkb = wkb_set_srid(&bytes, 4326).unwrap(); assert_eq!(ewkb, bytes); } #[test] fn set_srid_zero_strips_srid() { let bytes = wkb("SRID=4326;POINT (1 2)"); let ewkb = wkb_set_srid(&bytes, 0).unwrap(); assert_eq!(wkt_of(&ewkb), "POINT (1 2)"); } #[test] fn set_srid_negative_strips_srid() { let bytes = wkb("SRID=4326;POINT (1 2)"); let ewkb = wkb_set_srid(&bytes, -1).unwrap(); assert_eq!(wkt_of(&ewkb), "POINT (1 2)"); } #[test] fn set_srid_linestring() { let bytes = wkb("LINESTRING (0 0, 1 1)"); let ewkb = wkb_set_srid(&bytes, 4326).unwrap(); assert_eq!(wkt_of(&ewkb), "SRID=4326;LINESTRING (0 0, 1 1)"); } #[test] fn set_srid_big_endian_adds_srid_preserving_header_and_payload() { let bytes = from_hex(BE_POINT_HEX); let ewkb = wkb_set_srid(&bytes, 4326).unwrap(); assert_eq!(ewkb, from_hex(BE_SRID_POINT_HEX)); } #[test] fn set_srid_big_endian_replaces_srid_preserving_payload() { let bytes = from_hex(BE_SRID3_POINT_HEX); let ewkb = wkb_set_srid(&bytes, 4).unwrap(); assert_eq!(ewkb, from_hex(BE_SRID4_POINT_HEX)); } #[test] fn set_srid_multipolygon_falls_back_and_normalises_round_trip() { let bytes = wkb("MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); let ewkb = wkb_set_srid(&bytes, 4326).unwrap(); assert_eq!( wkt_of(&ewkb), "SRID=4326;MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))" ); } #[test] fn set_srid_iso_point_z_normalises_to_ewkb() { let bytes = make_iso_point_z(); let ewkb = wkb_set_srid(&bytes, 4326).unwrap(); assert_eq!(wkt_of(&ewkb), "SRID=4326;POINT Z (1 2 3)"); } #[test] fn set_srid_empty_bytes_errors() { assert!(wkb_set_srid(&[], 4326).is_err()); } // ── round-trip consistency ──────────────────────────────────────────────────── #[test] fn strip_then_set_round_trips_srid() { let original = wkb("SRID=4326;POINT (1 2)"); let stripped = wkb_strip_srid(&original).unwrap(); let restored = wkb_set_srid(&stripped, 4326).unwrap(); assert_eq!(wkt_of(&restored), "SRID=4326;POINT (1 2)"); } #[test] fn header_srid_consistent_with_strip() { let bytes = wkb("SRID=4326;MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); assert_eq!(wkb_header_srid(&bytes).unwrap(), Some(4326)); let stripped = wkb_strip_srid(&bytes).unwrap(); assert_eq!(wkb_header_srid(&stripped).unwrap(), None); } // ── wkb_strip_srid_writer ──────────────────────────────────────────────────── fn apply_writer(len: usize, writer: WkbWriter<'_>) -> Vec { let mut buf = vec![0u8; len]; writer.write_into(&mut buf); buf } #[test] fn strip_writer_has_srid_fast_path() { let bytes = wkb("SRID=4326;POINT (1 2)"); let (len, writer) = wkb_strip_srid_writer(&bytes).unwrap(); let out = apply_writer(len, writer); assert_eq!(wkt_of(&out), "POINT (1 2)"); } #[test] fn strip_writer_no_srid_fast_path() { let bytes = wkb("POINT (1 2)"); let (len, writer) = wkb_strip_srid_writer(&bytes).unwrap(); let out = apply_writer(len, writer); assert_eq!(out, bytes); } #[test] fn strip_writer_big_endian_preserves_header_and_payload() { let bytes = from_hex(BE_SRID_POINT_HEX); let (len, writer) = wkb_strip_srid_writer(&bytes).unwrap(); let out = apply_writer(len, writer); assert_eq!(out, from_hex(BE_POINT_HEX)); } #[test] fn strip_writer_multipolygon_fallback() { let bytes = wkb("SRID=4326;MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); let (len, writer) = wkb_strip_srid_writer(&bytes).unwrap(); let out = apply_writer(len, writer); assert_eq!(wkt_of(&out), "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); } #[test] fn strip_writer_empty_errors() { assert!(wkb_strip_srid_writer(&[]).is_err()); } // ── wkb_set_srid_writer ────────────────────────────────────────────────────── #[test] fn set_writer_adds_srid_fast_path() { let bytes = wkb("POINT (1 2)"); let (len, writer) = wkb_set_srid_writer(&bytes, 4326).unwrap(); let out = apply_writer(len, writer); assert_eq!(wkt_of(&out), "SRID=4326;POINT (1 2)"); } #[test] fn set_writer_replaces_srid_fast_path() { let bytes = wkb("SRID=4326;POINT (1 2)"); let (len, writer) = wkb_set_srid_writer(&bytes, 3857).unwrap(); let out = apply_writer(len, writer); assert_eq!(wkt_of(&out), "SRID=3857;POINT (1 2)"); } #[test] fn set_writer_big_endian_adds_srid_preserving_header_and_payload() { let bytes = from_hex(BE_POINT_HEX); let (len, writer) = wkb_set_srid_writer(&bytes, 4326).unwrap(); let out = apply_writer(len, writer); assert_eq!(out, from_hex(BE_SRID_POINT_HEX)); } #[test] fn set_writer_big_endian_replaces_srid_preserving_payload() { let bytes = from_hex(BE_SRID3_POINT_HEX); let (len, writer) = wkb_set_srid_writer(&bytes, 4).unwrap(); let out = apply_writer(len, writer); assert_eq!(out, from_hex(BE_SRID4_POINT_HEX)); } #[test] fn set_writer_noop_when_srid_matches() { let bytes = wkb("SRID=4326;POINT (1 2)"); let (len, writer) = wkb_set_srid_writer(&bytes, 4326).unwrap(); let out = apply_writer(len, writer); assert_eq!(out, bytes); } #[test] fn set_writer_negative_srid_strips() { let bytes = wkb("SRID=4326;POINT (1 2)"); let (len, writer) = wkb_set_srid_writer(&bytes, -1).unwrap(); let out = apply_writer(len, writer); assert_eq!(wkt_of(&out), "POINT (1 2)"); } #[test] fn set_writer_multipolygon_fallback() { let bytes = wkb("MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"); let (len, writer) = wkb_set_srid_writer(&bytes, 4326).unwrap(); let out = apply_writer(len, writer); assert_eq!( wkt_of(&out), "SRID=4326;MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))" ); } #[test] fn set_writer_empty_errors() { assert!(wkb_set_srid_writer(&[], 4326).is_err()); } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/tests/generic_converters.rs000066400000000000000000001013451521227137000312560ustar00rootroot00000000000000/// Tests for the generic to_wkb / to_wkt / to_hex_wkb helpers. use wkb_wkt_converter::{ hex_wkb_to_wkt, to_hex_wkb as core_to_hex_wkb, to_wkb as core_to_wkb, to_wkt as core_to_wkt, wkb_to_wkt as core_wkb_to_wkt, wkt_to_hex_wkb as core_wkt_to_hex_wkb, wkt_to_wkb as core_wkt_to_wkb, Input, Result, SridMode, }; // Big-endian WKB for POINT (1 2): byte-order=0x00, type=0x00000001 (BE), // X=1.0 (BE f64), Y=2.0 (BE f64). const BE_POINT_HEX: &str = "00000000013FF00000000000004000000000000000"; // Big-endian EWKB for SRID=4326;POINT (1 2). const BE_SRID_POINT_HEX: &str = "0020000001000010E63FF00000000000004000000000000000"; // Little-endian WKB with the EWKB SRID flag set in the type word but only 5 // bytes total (no room for the 4-byte SRID value). Used to exercise the // malformed-WKB fallback path. const LE_SHORT_SRID_HEX: &str = "0103000020"; // Little-endian POINT header with no coordinate body. const LE_SHORT_POINT_HEX: &str = "0101000000"; // Uppercase hex-encoded little-endian WKB for POINT (1 2) without SRID. // byte-order=0x01, type=0x00000001 (LE), X=1.0 (LE f64), Y=2.0 (LE f64). const POINT_HEX: &str = "0101000000000000000000F03F0000000000000040"; // Uppercase hex-encoded little-endian EWKB for SRID=4326;POINT (1 2). // byte-order=0x01, type=0x20000001 (LE, SRID flag), SRID=4326 (LE u32), // X=1.0 (LE f64), Y=2.0 (LE f64). const SRID_POINT_HEX: &str = "0101000020E6100000000000000000F03F0000000000000040"; // ── helpers ────────────────────────────────────────────────────────────────── fn wkb_to_wkt(wkb: &[u8]) -> Result { core_wkb_to_wkt(wkb, SridMode::Auto) } fn wkt_to_wkb(wkt: &str) -> Result> { core_wkt_to_wkb(wkt, SridMode::Auto) } fn wkt_to_hex_wkb(wkt: &str) -> Result { core_wkt_to_hex_wkb(wkt, SridMode::Auto) } fn to_wkb(text: &str, srid: SridMode) -> Result> { core_to_wkb(Input::Text(text), srid) } fn to_wkb_bytes(wkb: &[u8], srid: SridMode) -> Result> { core_to_wkb(Input::Wkb(wkb), srid) } fn to_wkt(text: &str, srid: SridMode, normalize_wkt: bool) -> Result { core_to_wkt(Input::Text(text), srid, normalize_wkt) } fn to_wkt_bytes(wkb: &[u8], srid: SridMode, normalize_wkt: bool) -> Result { core_to_wkt(Input::Wkb(wkb), srid, normalize_wkt) } fn to_hex_wkb(text: &str, srid: SridMode) -> Result { core_to_hex_wkb(Input::Text(text), srid) } fn to_hex_wkb_bytes(wkb: &[u8], srid: SridMode) -> Result { core_to_hex_wkb(Input::Wkb(wkb), srid) } fn hex(wkt: &str) -> String { wkt_to_hex_wkb(wkt).unwrap() } fn le_point_hex_with_type(type_u32: u32, srid: Option) -> String { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&type_u32.to_le_bytes()); if let Some(srid) = srid { wkb.extend_from_slice(&srid.to_le_bytes()); } wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); hex::encode_upper(wkb) } fn le_point_hex_with_coords(coords: &[f64]) -> String { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&1u32.to_le_bytes()); for coord in coords { wkb.extend_from_slice(&coord.to_le_bytes()); } hex::encode_upper(wkb) } fn le_linestring_hex_with_coords(coords: &[(f64, f64)]) -> String { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&2u32.to_le_bytes()); wkb.extend_from_slice(&(coords.len() as u32).to_le_bytes()); for (x, y) in coords { wkb.extend_from_slice(&x.to_le_bytes()); wkb.extend_from_slice(&y.to_le_bytes()); } hex::encode_upper(wkb) } // ── to_wkb: input detection ───────────────────────────────────────────── #[test] fn wkb_from_wkt_matches_direct() { let via_text = to_wkb("POINT (1 2)", SridMode::Auto).unwrap(); let direct = wkt_to_wkb("POINT (1 2)").unwrap(); assert_eq!(via_text, direct); } #[test] fn wkb_from_hex_wkb_matches_direct() { let h = hex("POINT (1 2)"); let via_text = to_wkb(&h, SridMode::Auto).unwrap(); let direct = wkt_to_wkb("POINT (1 2)").unwrap(); assert_eq!(via_text, direct); } #[test] fn wkb_from_wkt_with_leading_whitespace() { let via_text = to_wkb(" POINT (1 2) ", SridMode::Auto).unwrap(); let direct = wkt_to_wkb("POINT (1 2)").unwrap(); assert_eq!(via_text, direct); } #[test] fn wkb_input_auto_returns_raw_bytes_without_validation() { let raw = POINT_HEX.as_bytes(); let wkb = to_wkb_bytes(raw, SridMode::Auto).unwrap(); assert_eq!(wkb, raw); } #[test] fn wkb_input_auto_does_not_decode_ascii_hex_bytes() { let raw = POINT_HEX.as_bytes(); let wkb = to_wkb_bytes(raw, SridMode::Auto).unwrap(); assert_ne!(wkb, hex::decode(POINT_HEX).unwrap()); assert_eq!( to_hex_wkb_bytes(raw, SridMode::Auto).unwrap(), hex::encode_upper(raw) ); } // ── to_wkb: SridMode::Auto ────────────────────────────────────────────── #[test] fn wkb_auto_preserves_srid_from_ewkt() { let wkb = to_wkb("SRID=4326;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn wkb_auto_preserves_no_srid_from_wkt() { let wkb = to_wkb("POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkb_auto_preserves_srid_from_hex_ewkb() { let h = hex("SRID=4326;POINT (1 2)"); let wkb = to_wkb(&h, SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn wkb_auto_preserves_no_srid_from_plain_hex_wkb() { let h = hex("POINT (1 2)"); let wkb = to_wkb(&h, SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } // ── to_wkb: SridMode::Strip ───────────────────────────────────────────── #[test] fn wkb_strip_removes_srid_from_ewkt() { let wkb = to_wkb("SRID=4326;POINT (1 2)", SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkb_strip_removes_srid_from_hex_ewkb() { let h = hex("SRID=4326;POINT (1 2)"); let wkb = to_wkb(&h, SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkb_strip_noop_when_no_srid_in_wkt() { let wkb = to_wkb("POINT (1 2)", SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } // ── to_wkb: SridMode::Set ─────────────────────────────────────────────── #[test] fn wkb_set_adds_srid_to_plain_wkt() { let wkb = to_wkb("POINT (1 2)", SridMode::Set(4326)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn wkb_set_overrides_existing_srid_in_ewkt() { let wkb = to_wkb("SRID=4326;POINT (1 2)", SridMode::Set(3857)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=3857;POINT (1 2)"); } #[test] fn wkb_set_adds_srid_to_plain_hex_wkb() { let h = hex("POINT (1 2)"); let wkb = to_wkb(&h, SridMode::Set(4326)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn wkb_set_overrides_srid_in_hex_ewkb() { let h = hex("SRID=4326;POINT (1 2)"); let wkb = to_wkb(&h, SridMode::Set(3857)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=3857;POINT (1 2)"); } #[test] fn wkb_input_strip_and_set_use_raw_bytes() { let srid_wkb = hex::decode(SRID_POINT_HEX).unwrap(); let stripped = to_wkb_bytes(&srid_wkb, SridMode::Strip).unwrap(); assert_eq!(stripped, hex::decode(POINT_HEX).unwrap()); let set = to_wkb_bytes(&srid_wkb, SridMode::Set(3857)).unwrap(); assert_eq!(wkb_to_wkt(&set).unwrap(), "SRID=3857;POINT (1 2)"); } // ── edge cases ─────────────────────────────────────────────────────────────── #[test] fn empty_string_errors() { assert!(to_wkb("", SridMode::Auto).is_err()); assert!(to_wkt("", SridMode::Auto, true).is_err()); assert!(to_hex_wkb("", SridMode::Auto).is_err()); assert!(to_wkt("", SridMode::Auto, false).is_err()); } #[test] fn decode_hex_lowercase_accepted() { // parse_hex_nibble must handle b'a'..=b'f' let lower = hex("POINT (1 2)").to_lowercase(); let via_lower = to_wkb(&lower, SridMode::Auto).unwrap(); assert_eq!(via_lower, wkt_to_wkb("POINT (1 2)").unwrap()); } #[test] fn decode_hex_invalid_low_nibble_errors() { // '0' is a valid high nibble; 'Z' is an invalid low nibble. // try_decode_hex("0Z") returns None (not valid hex), so to_wkb // would silently route to WKT parsing. hex_wkb_to_wkt uses decode_hex // directly and therefore returns a descriptive error. assert!(hex_wkb_to_wkt("0Z", SridMode::Auto).is_err()); } #[test] fn strip_ewkt_prefix_no_semicolon_returns_unchanged() { // "SRID=" present but no semicolon → strip_ewkt_prefix falls through unchanged assert_eq!( to_wkt("SRID=4326POINT (1 2)", SridMode::Strip, false).unwrap(), "SRID=4326POINT (1 2)" ); } #[test] fn odd_length_hex_string_errors() { // Odd-length all-hex input: try_decode_hex returns None (odd length) so // the input falls through to WKT parsing, which also rejects it. assert!(to_wkb("ABC", SridMode::Auto).is_err()); assert!(to_wkt("ABC", SridMode::Auto, true).is_err()); assert!(to_hex_wkb("ABC", SridMode::Auto).is_err()); } /// SRID=0 is "unknown" per PostGIS convention; Set(0) behaves like Strip. #[test] fn srid_set_zero_acts_as_strip() { let wkb = to_wkb("SRID=4326;POINT (1 2)", SridMode::Set(0)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } // ── EWKT with negative/zero SRID prefix ───────────────────────────────── /// EWKT with SRID=0 prefix: tokenizer normalises to no-SRID, treated as plain WKT. #[test] fn ewkt_zero_srid_prefix_acts_as_plain_wkt_for_to_wkb() { let wkb = to_wkb("SRID=0;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } /// EWKT with SRID=-1 prefix: tokenizer normalises to no-SRID, treated as plain WKT. #[test] fn ewkt_negative_srid_prefix_acts_as_plain_wkt_for_to_wkb() { let wkb = to_wkb("SRID=-1;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn ewkt_negative_srid_prefix_acts_as_plain_wkt_for_to_wkt() { assert_eq!( to_wkt("SRID=-1;POINT (1 2)", SridMode::Auto, true).unwrap(), "POINT (1 2)" ); } #[test] fn ewkt_negative_srid_prefix_acts_as_plain_wkt_for_to_hex_wkb() { let h = to_hex_wkb("SRID=-1;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(&h).unwrap()).unwrap(), "POINT (1 2)" ); } // ── to_wkt: input detection and normalisation ─────────────────────────── #[test] fn wkt_from_wkt_normalises_casing_and_spacing() { assert_eq!( to_wkt("point(1 2)", SridMode::Auto, true).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_from_hex_wkb_roundtrip() { let h = hex("LINESTRING (0 0, 1 1)"); assert_eq!( to_wkt(&h, SridMode::Auto, true).unwrap(), "LINESTRING (0 0, 1 1)" ); } // ── to_wkt: SridMode::Auto ────────────────────────────────────────────── #[test] fn wkt_auto_preserves_srid_from_ewkt() { assert_eq!( to_wkt("SRID=4326;POINT (1 2)", SridMode::Auto, true).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkt_auto_preserves_no_srid_from_wkt() { assert_eq!( to_wkt("POINT (1 2)", SridMode::Auto, true).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_auto_preserves_srid_from_hex_ewkb() { let h = hex("SRID=4326;POINT (1 2)"); assert_eq!( to_wkt(&h, SridMode::Auto, true).unwrap(), "SRID=4326;POINT (1 2)" ); } // ── to_wkt: SridMode::Strip ───────────────────────────────────────────── #[test] fn wkt_strip_removes_srid_from_ewkt() { assert_eq!( to_wkt("SRID=4326;POINT (1 2)", SridMode::Strip, true).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_strip_removes_srid_from_hex_ewkb() { let h = hex("SRID=4326;POINT (1 2)"); assert_eq!(to_wkt(&h, SridMode::Strip, true).unwrap(), "POINT (1 2)"); } // ── to_wkt: SridMode::Set ─────────────────────────────────────────────── #[test] fn wkt_set_adds_srid_to_plain_wkt() { assert_eq!( to_wkt("POINT (1 2)", SridMode::Set(4326), true).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkt_set_overrides_existing_srid_in_ewkt() { assert_eq!( to_wkt("SRID=4326;POINT (1 2)", SridMode::Set(3857), true).unwrap(), "SRID=3857;POINT (1 2)" ); } #[test] fn wkt_set_adds_srid_to_plain_hex_wkb() { let h = hex("POINT (1 2)"); assert_eq!( to_wkt(&h, SridMode::Set(4326), true).unwrap(), "SRID=4326;POINT (1 2)" ); } // ── to_wkt: normalize_wkt=false ───────────────────────────────────────── #[test] fn wkt_no_normalize_returns_wkt_as_is() { assert_eq!( to_wkt("point(1 2)", SridMode::Auto, false).unwrap(), "point(1 2)" ); } #[test] fn wkt_no_normalize_auto_preserves_srid_prefix() { assert_eq!( to_wkt("SRID=4326;POINT (1 2)", SridMode::Auto, false).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkt_no_normalize_auto_strips_negative_srid_prefix() { assert_eq!( to_wkt("SRID=-1;POINT (1 2)", SridMode::Auto, false).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_no_normalize_auto_strips_zero_srid_prefix() { assert_eq!( to_wkt("SRID=0;POINT (1 2)", SridMode::Auto, false).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_no_normalize_auto_unparseable_srid_returned_as_is() { // SRID value is not a valid i32 → strip_unknown_srid_prefix falls through unchanged assert_eq!( to_wkt("SRID=notanint;POINT (1 2)", SridMode::Auto, false).unwrap(), "SRID=notanint;POINT (1 2)" ); } #[test] fn wkt_no_normalize_auto_srid_prefix_without_semicolon_returned_as_is() { // "SRID=" present but no ";" → strip_unknown_srid_prefix falls through unchanged assert_eq!( to_wkt("SRID=4326POINT (1 2)", SridMode::Auto, false).unwrap(), "SRID=4326POINT (1 2)" ); } #[test] fn wkt_no_normalize_strip_removes_srid_prefix() { assert_eq!( to_wkt("SRID=4326;POINT (1 2)", SridMode::Strip, false).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_no_normalize_strip_noop_when_no_srid() { assert_eq!( to_wkt("POINT (1 2)", SridMode::Strip, false).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_no_normalize_set_adds_srid_prefix() { assert_eq!( to_wkt("POINT (1 2)", SridMode::Set(4326), false).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkt_no_normalize_set_overrides_existing_srid() { assert_eq!( to_wkt("SRID=4326;POINT (1 2)", SridMode::Set(3857), false).unwrap(), "SRID=3857;POINT (1 2)" ); } #[test] fn wkt_no_normalize_hex_input_still_normalises() { // normalize_wkt has no effect on hex WKB input; result is always normalised WKT. let h = hex("POINT (1 2)"); assert_eq!(to_wkt(&h, SridMode::Auto, false).unwrap(), "POINT (1 2)"); } #[test] fn wkb_input_to_wkt_ignores_normalize_wkt() { let wkb = wkt_to_wkb("SRID=4326;POINT (1 2)").unwrap(); assert_eq!( to_wkt_bytes(&wkb, SridMode::Auto, false).unwrap(), "SRID=4326;POINT (1 2)" ); assert_eq!( to_wkt_bytes(&wkb, SridMode::Strip, true).unwrap(), "POINT (1 2)" ); } #[test] fn wkb_input_to_wkt_invalid_bytes_error_even_without_normalize() { assert!(to_wkt_bytes(b"0101000000", SridMode::Auto, false).is_err()); } // ── to_hex_wkb ────────────────────────────────────────────────────────── #[test] fn hex_wkb_from_wkt_is_uppercase() { let h = to_hex_wkb("POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(h, h.to_uppercase()); } #[test] fn hex_wkb_from_wkt_roundtrip() { let h = to_hex_wkb("POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(&h).unwrap()).unwrap(), "POINT (1 2)" ); } #[test] fn hex_wkb_from_hex_wkb_roundtrip() { let original = hex("LINESTRING (0 0, 1 1)"); let result = to_hex_wkb(&original, SridMode::Auto).unwrap(); assert_eq!(result, original); } #[test] fn hex_wkb_strip_from_hex_without_srid_reencodes_same_bytes() { assert_eq!(to_hex_wkb(POINT_HEX, SridMode::Strip).unwrap(), POINT_HEX); } #[test] fn hex_wkb_strip_from_hex_removes_srid_header() { assert_eq!( to_hex_wkb(SRID_POINT_HEX, SridMode::Strip).unwrap(), POINT_HEX ); } #[test] fn hex_wkb_strip_from_big_endian_hex_preserves_byte_order() { assert_eq!( to_hex_wkb(BE_POINT_HEX, SridMode::Strip).unwrap(), BE_POINT_HEX ); } #[test] fn hex_wkb_set_adds_srid() { let h = to_hex_wkb("POINT (1 2)", SridMode::Set(4326)).unwrap(); let wkt = wkb_to_wkt(&hex::decode(&h).unwrap()).unwrap(); assert_eq!(wkt, "SRID=4326;POINT (1 2)"); } #[test] fn hex_wkb_set_from_hex_adds_srid_header() { assert_eq!( to_hex_wkb(POINT_HEX, SridMode::Set(4326)).unwrap(), SRID_POINT_HEX ); } #[test] fn hex_wkb_set_from_hex_noops_when_srid_already_matches() { assert_eq!( to_hex_wkb(SRID_POINT_HEX, SridMode::Set(4326)).unwrap(), SRID_POINT_HEX ); } #[test] fn hex_wkb_set_from_big_endian_hex_preserves_byte_order() { assert_eq!( to_hex_wkb(BE_POINT_HEX, SridMode::Set(4326)).unwrap(), BE_SRID_POINT_HEX ); } #[test] fn hex_wkb_strip_removes_srid() { let h = to_hex_wkb("SRID=4326;POINT (1 2)", SridMode::Strip).unwrap(); let wkt = wkb_to_wkt(&hex::decode(&h).unwrap()).unwrap(); assert_eq!(wkt, "POINT (1 2)"); } #[test] fn hex_wkb_from_wkb_input_auto_encodes_without_validation() { let malformed = hex::decode(LE_SHORT_POINT_HEX).unwrap(); assert_eq!( to_hex_wkb_bytes(&malformed, SridMode::Auto).unwrap(), LE_SHORT_POINT_HEX ); } #[test] fn hex_wkb_from_wkb_input_strip_and_set_use_raw_bytes() { let srid_wkb = hex::decode(SRID_POINT_HEX).unwrap(); assert_eq!( to_hex_wkb_bytes(&srid_wkb, SridMode::Strip).unwrap(), POINT_HEX ); assert_eq!( to_hex_wkb_bytes(&hex::decode(POINT_HEX).unwrap(), SridMode::Set(4326)).unwrap(), SRID_POINT_HEX ); } #[test] fn hex_wkb_from_wkb_input_strip_and_set_collection_fallbacks() { let collection = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))"; let with_srid = wkt_to_wkb(&format!("SRID=4326;{collection}")).unwrap(); let stripped_hex = to_hex_wkb_bytes(&with_srid, SridMode::Strip).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(stripped_hex).unwrap()).unwrap(), collection ); let plain = wkt_to_wkb(collection).unwrap(); let set_hex = to_hex_wkb_bytes(&plain, SridMode::Set(4326)).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(set_hex).unwrap()).unwrap(), format!("SRID=4326;{collection}") ); } // ── simple WKB/EWKB header fast-path coverage ──────────────────────────────── #[test] fn wkb_strip_noop_when_no_srid_in_hex_wkb() { // hex WKB without SRID flag: fast binary path returns bytes unchanged. let h = hex("POINT (1 2)"); let wkb = to_wkb(&h, SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkb_strip_big_endian_hex_preserves_byte_order() { let wkb = to_wkb(BE_POINT_HEX, SridMode::Strip).unwrap(); assert_eq!(wkb, hex::decode(BE_POINT_HEX).unwrap()); } #[test] fn wkb_input_big_endian_uses_header_fast_path_for_strip_and_set() { let be_wkb = hex::decode(BE_POINT_HEX).unwrap(); let stripped = to_wkb_bytes(&be_wkb, SridMode::Strip).unwrap(); assert_eq!(stripped, be_wkb); let set = to_wkb_bytes(&be_wkb, SridMode::Set(4326)).unwrap(); assert_eq!(set, hex::decode(BE_SRID_POINT_HEX).unwrap()); } #[test] fn wkb_input_invalid_byte_order_falls_back_and_errors() { assert!(to_wkb_bytes(&[0x99, 0x01, 0x00, 0x00, 0x00], SridMode::Strip).is_err()); } #[test] fn wkb_strip_malformed_short_srid_errors() { // LE WKB with SRID flag but only 5 bytes (no room for 4-byte SRID value): // The simple fast path rejects this header, so fallback round-trip fails. assert!(to_wkb(LE_SHORT_SRID_HEX, SridMode::Strip).is_err()); } #[test] fn wkb_set_big_endian_hex_preserves_byte_order() { let wkb = to_wkb(BE_POINT_HEX, SridMode::Set(4326)).unwrap(); assert_eq!(wkb, hex::decode(BE_SRID_POINT_HEX).unwrap()); } #[test] fn wkb_set_malformed_short_srid_errors() { // LE WKB with SRID flag but too short: the simple fast path rejects this // header, so fallback round-trip fails. assert!(to_wkb(LE_SHORT_SRID_HEX, SridMode::Set(4326)).is_err()); } #[test] fn wkb_input_strip_and_set_malformed_short_srid_errors() { let malformed = hex::decode(LE_SHORT_SRID_HEX).unwrap(); assert!(to_wkb_bytes(&malformed, SridMode::Strip).is_err()); assert!(to_wkb_bytes(&malformed, SridMode::Set(4326)).is_err()); } #[test] fn wkb_strip_short_point_header_passes_through() { let wkb = to_wkb(LE_SHORT_POINT_HEX, SridMode::Strip).unwrap(); assert_eq!(wkb, hex::decode(LE_SHORT_POINT_HEX).unwrap()); } #[test] fn wkb_set_short_point_header_patches_header_only() { let wkb = to_wkb(LE_SHORT_POINT_HEX, SridMode::Set(4326)).unwrap(); assert_eq!(wkb.len(), 9); assert_eq!(wkb[0], 1); assert_eq!( u32::from_le_bytes(wkb[1..5].try_into().unwrap()), 0x2000_0001 ); assert_eq!(u32::from_le_bytes(wkb[5..9].try_into().unwrap()), 4326); } #[test] fn wkb_input_short_point_header_uses_same_fast_path() { let malformed = hex::decode(LE_SHORT_POINT_HEX).unwrap(); assert_eq!( to_wkb_bytes(&malformed, SridMode::Strip).unwrap(), malformed ); let with_srid = to_wkb_bytes(&malformed, SridMode::Set(4326)).unwrap(); assert_eq!(with_srid.len(), 9); assert_eq!( u32::from_le_bytes(with_srid[1..5].try_into().unwrap()), 0x2000_0001 ); assert_eq!( u32::from_le_bytes(with_srid[5..9].try_into().unwrap()), 4326 ); } #[test] fn wkb_auto_hex_preserves_malformed_bytes_without_validation() { let wkb = to_wkb(LE_SHORT_POINT_HEX, SridMode::Auto).unwrap(); assert_eq!(wkb, hex::decode(LE_SHORT_POINT_HEX).unwrap()); assert_eq!( to_hex_wkb(&LE_SHORT_POINT_HEX.to_lowercase(), SridMode::Auto).unwrap(), LE_SHORT_POINT_HEX ); } #[test] fn wkb_strip_and_set_allow_non_finite_simple_fast_path_coords() { let partial_nan = le_point_hex_with_coords(&[f64::NAN, 2.0]); let stripped = to_wkb(&partial_nan, SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&stripped).unwrap(), "POINT (NaN 2)"); let with_srid = to_wkb(&partial_nan, SridMode::Set(4326)).unwrap(); assert_eq!(wkb_to_wkt(&with_srid).unwrap(), "SRID=4326;POINT (NaN 2)"); let infinity = le_point_hex_with_coords(&[f64::INFINITY, 2.0]); let stripped = to_wkb(&infinity, SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&stripped).unwrap(), "POINT (inf 2)"); let with_srid = to_wkb(&infinity, SridMode::Set(4326)).unwrap(); assert_eq!(wkb_to_wkt(&with_srid).unwrap(), "SRID=4326;POINT (inf 2)"); let linestring_nan = le_linestring_hex_with_coords(&[(0.0, 0.0), (1.0, f64::NAN)]); let stripped = to_wkb(&linestring_nan, SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&stripped).unwrap(), "LINESTRING (0 0, 1 NaN)"); let with_srid = to_wkb(&linestring_nan, SridMode::Set(4326)).unwrap(); assert_eq!( wkb_to_wkt(&with_srid).unwrap(), "SRID=4326;LINESTRING (0 0, 1 NaN)" ); } #[test] fn wkb_strip_and_set_accept_all_nan_point_empty() { let empty_point = le_point_hex_with_coords(&[f64::NAN, f64::NAN]); let stripped = to_wkb(&empty_point, SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&stripped).unwrap(), "POINT EMPTY"); let with_srid = to_wkb(&empty_point, SridMode::Set(4326)).unwrap(); assert_eq!(wkb_to_wkt(&with_srid).unwrap(), "SRID=4326;POINT EMPTY"); } #[test] fn wkb_strip_plain_point_with_trailing_bytes_passes_through() { let input = format!("{POINT_HEX}DEADBEEF"); assert_eq!( to_wkb(&input, SridMode::Strip).unwrap(), hex::decode(input).unwrap() ); } #[test] fn wkb_set_plain_point_with_trailing_bytes_patches_header_only() { let input = format!("{POINT_HEX}DEADBEEF"); let wkb = to_wkb(&input, SridMode::Set(4326)).unwrap(); assert_eq!(wkb.len(), hex::decode(input).unwrap().len() + 4); assert_eq!( u32::from_le_bytes(wkb[1..5].try_into().unwrap()), 0x2000_0001 ); assert_eq!(u32::from_le_bytes(wkb[5..9].try_into().unwrap()), 4326); assert_eq!(&wkb[wkb.len() - 4..], &[0xDE, 0xAD, 0xBE, 0xEF]); } #[test] fn to_wkt_hex_with_trailing_bytes_errors() { let input = format!("{POINT_HEX}DEADBEEF"); assert!(to_wkt(&input, SridMode::Auto, true).is_err()); assert!(to_wkt(&input, SridMode::Strip, true).is_err()); assert!(to_wkt(&input, SridMode::Set(4326), true).is_err()); } #[test] fn wkb_strip_short_linestring_header_passes_through() { let wkb = to_wkb("0102000000", SridMode::Strip).unwrap(); assert_eq!(wkb, hex::decode("0102000000").unwrap()); } #[test] fn wkb_strip_valid_empty_linestring_uses_fast_path() { let wkb = to_wkb(&hex("LINESTRING EMPTY"), SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "LINESTRING EMPTY"); } #[test] fn wkb_strip_valid_polygon_uses_fast_path() { let wkb = to_wkb(&hex("POLYGON ((0 0, 1 0, 1 1, 0 0))"), SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POLYGON ((0 0, 1 0, 1 1, 0 0))"); } #[test] fn wkb_strip_canonicalizes_unknown_high_type_bits() { let h = le_point_hex_with_type(0x1000_0001, None); let wkb = to_wkb(&h, SridMode::Strip).unwrap(); assert_eq!(u32::from_le_bytes(wkb[1..5].try_into().unwrap()), 1); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkb_set_canonicalizes_unknown_high_type_bits_when_srid_matches() { let h = le_point_hex_with_type(0x3000_0001, Some(4326)); let wkb = to_wkb(&h, SridMode::Set(4326)).unwrap(); assert_eq!( u32::from_le_bytes(wkb[1..5].try_into().unwrap()), 0x2000_0001 ); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn wkb_set_canonicalizes_unknown_high_type_bits_when_adding_srid() { let h = le_point_hex_with_type(0x1000_0001, None); let wkb = to_wkb(&h, SridMode::Set(4326)).unwrap(); assert_eq!( u32::from_le_bytes(wkb[1..5].try_into().unwrap()), 0x2000_0001 ); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } // ── to_wkt: normalize_wkt=false with hex-digit-starting non-hex input ─── #[test] fn wkt_no_normalize_hex_start_non_hex_body_returned_as_is() { // Input begins with a hex digit ('0') but is not valid hex (contains 'X'). // The fast O(1) first-char check does NOT apply (first byte IS a hex digit), // so try_decode_hex is attempted, fails, and the WKT is returned as-is. assert_eq!(to_wkt("0XTEST", SridMode::Auto, false).unwrap(), "0XTEST"); assert_eq!(to_wkt("0XTEST", SridMode::Strip, false).unwrap(), "0XTEST"); assert_eq!( to_wkt("0XTEST", SridMode::Set(4326), false).unwrap(), "SRID=4326;0XTEST" ); } #[test] fn wkt_no_normalize_hex_start_later_non_hex_body_returned_as_is() { assert_eq!(to_wkt("00X0", SridMode::Auto, false).unwrap(), "00X0"); } #[test] fn wkt_no_normalize_odd_length_all_hex_returned_as_unvalidated_wkt() { assert_eq!(to_wkt("ABC", SridMode::Auto, false).unwrap(), "ABC"); assert_eq!(to_wkt("ABC", SridMode::Strip, false).unwrap(), "ABC"); assert_eq!( to_wkt("ABC", SridMode::Set(4326), false).unwrap(), "SRID=4326;ABC" ); } // ── new tests for Change 2–5 ────────────────────────────────────────────────── #[test] fn decode_hex_empty_string_errors() { assert!(hex_wkb_to_wkt("", SridMode::Auto).is_err()); } #[test] fn auto_be_hex_input_returned_as_be_bytes() { // Big-endian WKB passes through SridMode::Auto without normalisation. // The first byte of the result must be 0x00 (big-endian byte-order marker). let result = to_wkb(BE_POINT_HEX, SridMode::Auto).unwrap(); assert_eq!(result[0], 0x00, "expected big-endian marker byte 0x00"); } #[test] fn to_hex_wkb_lowercase_hex_normalised_to_uppercase() { // Lowercase hex input under SridMode::Auto is validated as hex text and // uppercased without WKB structure validation. let lowercase = POINT_HEX.to_lowercase(); let result = to_hex_wkb(&lowercase, SridMode::Auto).unwrap(); assert_eq!(result, POINT_HEX); } #[test] fn to_hex_wkb_auto_uppercases_without_wkb_validation() { assert_eq!( to_hex_wkb("0101000000deadbeef", SridMode::Auto).unwrap(), "0101000000DEADBEEF" ); } #[test] fn to_hex_wkb_auto_invalid_hex_falls_back_to_wkt_errors() { assert!(to_hex_wkb("ZZ", SridMode::Auto).is_err()); assert!(to_hex_wkb("0Z", SridMode::Auto).is_err()); } #[test] fn set_srid_noop_when_srid_already_matches() { // Calling to_wkb with SridMode::Set(4326) on hex EWKB that already // has SRID=4326 should return the same WKB bytes (Cow::Borrowed fast path). let input_bytes = hex::decode(SRID_POINT_HEX).unwrap(); let result = to_wkb(SRID_POINT_HEX, SridMode::Set(4326)).unwrap(); assert_eq!(result, input_bytes); // Verify the round-trip still produces the correct WKT. assert_eq!(wkb_to_wkt(&result).unwrap(), "SRID=4326;POINT (1 2)"); } // ── Fix 1: ISO dimensional types and collection types fall back to round-trip ── #[test] fn strip_iso_xyz_point_normalizes_to_ewkb() { // ISO WKB POINT Z uses type code 1001; the fast path must not pass it // through — it falls back to the round-trip which normalises to EWKB. const ISO_XYZ: &str = "01E9030000000000000000F03F00000000000000400000000000000840"; let wkb = to_wkb(ISO_XYZ, SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT Z (1 2 3)"); // EWKB POINT Z type word in LE is 0x80000001 → bytes [01, 00, 00, 80] assert_eq!(&wkb[1..5], &[0x01, 0x00, 0x00, 0x80]); } #[test] fn set_iso_xyz_point_normalizes_to_ewkb_with_srid() { const ISO_XYZ: &str = "01E9030000000000000000F03F00000000000000400000000000000840"; let wkb = to_wkb(ISO_XYZ, SridMode::Set(4326)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT Z (1 2 3)"); } #[test] fn strip_geometry_collection_fallback_scrubs_nested_srids() { const NESTED_SRID_GC: &str = concat!( "0107000020E6100000", // GC, SRID=4326 "01000000", // 1 sub-geometry "01", // sub-geom LE marker "01000020", // POINT with SRID flag (malformed — sub-geometries "E6100000", // shouldn't carry SRIDs per EWKB spec) "000000000000F03F", // x=1.0 "0000000000000040", // y=2.0 ); let wkb = to_wkb(NESTED_SRID_GC, SridMode::Strip).unwrap(); // Collection types now fall back to the full parse/round-trip path, // which removes SRIDs from nested sub-geometries to preserve the repo // invariant that only the top-level header may carry an SRID. assert_eq!( wkb_to_wkt(&wkb).unwrap(), "GEOMETRYCOLLECTION (POINT (1 2))" ); assert_eq!(u32::from_le_bytes(wkb[1..5].try_into().unwrap()), 7); // Sub-geometry type word is normalized to plain POINT. assert_eq!( u32::from_le_bytes(wkb[10..14].try_into().unwrap()), 0x0000_0001 ); } // ── Fix 2: Invalid SRID prefix errors under SridMode::Set (WKT path) ────────── #[test] fn set_srid_with_invalid_wkt_srid_prefix_errors() { // Old code silently accepted SRID=abc; under Set because it stripped the // prefix without validating it. The fix validates via wkt_to_wkb_split_srid. assert!(to_wkb("SRID=abc;POINT (1 2)", SridMode::Set(4326)).is_err()); assert!(to_hex_wkb("SRID=abc;POINT (1 2)", SridMode::Set(4326)).is_err()); } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/tests/wkb_to_wkt.rs000066400000000000000000000510751521227137000275460ustar00rootroot00000000000000/// WKB → WKT tests. /// /// Most fixtures are generated by `wkt_to_wkb` so we avoid hand-encoding bytes /// (error-prone). The exceptions are tests that specifically exercise known /// external binary representations (ISO WKB, SRID-embedded EWKB). use wkb_wkt_converter::{ hex_wkb_to_wkt, wkb_to_wkt as core_wkb_to_wkt, wkb_to_wkt_split_srid, wkt_to_hex_wkb as core_wkt_to_hex_wkb, wkt_to_wkb as core_wkt_to_wkb, Result, SridMode, }; fn wkb_to_wkt(wkb: &[u8]) -> Result { core_wkb_to_wkt(wkb, SridMode::Auto) } fn wkt_to_wkb(wkt: &str) -> Result> { core_wkt_to_wkb(wkt, SridMode::Auto) } fn wkt_to_hex_wkb(wkt: &str) -> Result { core_wkt_to_hex_wkb(wkt, SridMode::Auto) } fn wkb_of(wkt: &str) -> Vec { wkt_to_wkb(wkt).unwrap() } fn le_header(geom_type: u32) -> Vec { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&geom_type.to_le_bytes()); wkb } fn le_point_with_coords(geom_type: u32, coords: &[f64]) -> Vec { let mut wkb = le_header(geom_type); for coord in coords { wkb.extend_from_slice(&coord.to_le_bytes()); } wkb } fn nested_geometrycollection_wkb(collection_depth: usize) -> Vec { let mut wkb = Vec::new(); for _ in 0..collection_depth { wkb.push(0x01); wkb.extend_from_slice(&7u32.to_le_bytes()); wkb.extend_from_slice(&1u32.to_le_bytes()); } wkb.extend_from_slice(&wkb_of("POINT (1 2)")); wkb } // ── Point ──────────────────────────────────────────────────────────────────── #[test] fn point_xy() { assert_eq!(wkb_to_wkt(&wkb_of("POINT (1 2)")).unwrap(), "POINT (1 2)"); } #[test] fn point_xyz() { assert_eq!( wkb_to_wkt(&wkb_of("POINT Z (1 2 3)")).unwrap(), "POINT Z (1 2 3)" ); } #[test] fn point_xym() { assert_eq!( wkb_to_wkt(&wkb_of("POINT M (1 2 3)")).unwrap(), "POINT M (1 2 3)" ); } #[test] fn point_xyzm() { assert_eq!( wkb_to_wkt(&wkb_of("POINT ZM (1 2 3 4)")).unwrap(), "POINT ZM (1 2 3 4)" ); } #[test] fn point_empty() { assert_eq!(wkb_to_wkt(&wkb_of("POINT EMPTY")).unwrap(), "POINT EMPTY"); } #[test] fn point_empty_z() { assert_eq!( wkb_to_wkt(&wkb_of("POINT Z EMPTY")).unwrap(), "POINT Z EMPTY" ); } #[test] fn point_partial_nan_outputs_invalid_wkt() { let wkb = le_point_with_coords(1, &[f64::NAN, 2.0]); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (NaN 2)"); } #[test] fn point_infinity_outputs_invalid_wkt() { let wkb = le_point_with_coords(1, &[f64::INFINITY, 2.0]); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (inf 2)"); } #[test] fn point_negative_zero_formats_as_zero() { assert_eq!(wkb_to_wkt(&wkb_of("POINT (-0 -0)")).unwrap(), "POINT (0 0)"); } /// Externally-crafted EWKB with SRID=4326 for POINT (1 2). /// Byte layout: 01 01000020 E6100000 000000000000F03F 0000000000000040 #[test] fn point_with_srid_embedded_ewkb() { let mut wkb = vec![0x01u8]; // LE wkb.extend_from_slice(&0x2000_0001u32.to_le_bytes()); // type=Point | EWKB_SRID wkb.extend_from_slice(&4326u32.to_le_bytes()); // SRID wkb.extend_from_slice(&1.0f64.to_le_bytes()); // x wkb.extend_from_slice(&2.0f64.to_le_bytes()); // y assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn point_split_srid() { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&0x2000_0001u32.to_le_bytes()); wkb.extend_from_slice(&4326u32.to_le_bytes()); wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); let (wkt, srid) = wkb_to_wkt_split_srid(&wkb).unwrap(); assert_eq!(wkt, "POINT (1 2)"); assert_eq!(srid, Some(4326)); } #[test] fn point_no_srid_split() { let (wkt, srid) = wkb_to_wkt_split_srid(&wkb_of("POINT (1 2)")).unwrap(); assert_eq!(wkt, "POINT (1 2)"); assert_eq!(srid, None); } #[test] fn wkb_to_wkt_auto_preserves_srid() { let wkb = wkb_of("SRID=4326;POINT (1 2)"); assert_eq!( core_wkb_to_wkt(&wkb, SridMode::Auto).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkb_to_wkt_strip_removes_srid() { let wkb = wkb_of("SRID=4326;POINT (1 2)"); assert_eq!( core_wkb_to_wkt(&wkb, SridMode::Strip).unwrap(), "POINT (1 2)" ); } #[test] fn wkb_to_wkt_strip_without_srid_is_noop() { let wkb = wkb_of("POINT (1 2)"); assert_eq!( core_wkb_to_wkt(&wkb, SridMode::Strip).unwrap(), "POINT (1 2)" ); } #[test] fn wkb_to_wkt_set_adds_srid() { let wkb = wkb_of("POINT (1 2)"); assert_eq!( core_wkb_to_wkt(&wkb, SridMode::Set(4326)).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkb_to_wkt_set_overrides_srid() { let wkb = wkb_of("SRID=4326;POINT (1 2)"); assert_eq!( core_wkb_to_wkt(&wkb, SridMode::Set(3857)).unwrap(), "SRID=3857;POINT (1 2)" ); } /// SRID=0 is "unknown" per PostGIS convention; Set(0) behaves like Strip. #[test] fn wkb_to_wkt_set_zero_acts_as_strip() { let wkb = wkb_of("SRID=4326;POINT (1 2)"); assert_eq!( core_wkb_to_wkt(&wkb, SridMode::Set(0)).unwrap(), "POINT (1 2)" ); } /// SRID=-1 ≤ 0 → treated as unknown; Set(-1) behaves like Strip. #[test] fn wkb_to_wkt_set_negative_one_acts_as_strip() { let wkb = wkb_of("SRID=4326;POINT (1 2)"); assert_eq!( core_wkb_to_wkt(&wkb, SridMode::Set(-1)).unwrap(), "POINT (1 2)" ); } /// A PostGIS-generated EWKB with SRID=-1 (0xFFFFFFFF LE) is output as plain WKT. #[test] fn wkb_from_postgis_with_srid_minus_one_outputs_plain_wkt() { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&0x2000_0001u32.to_le_bytes()); // type=Point | EWKB_SRID wkb.extend_from_slice(&(-1i32).to_le_bytes()); // SRID = -1 (unknown) wkb.extend_from_slice(&1.0f64.to_le_bytes()); // x wkb.extend_from_slice(&2.0f64.to_le_bytes()); // y assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); let (plain, srid) = wkb_to_wkt_split_srid(&wkb).unwrap(); assert_eq!(plain, "POINT (1 2)"); assert_eq!(srid, None); } // ── ISO WKB (non-EWKB, dimension via type offset) ──────────────────────────── /// ISO WKB encodes XYZ as type+1000. Verify we decode it correctly. #[test] fn iso_wkb_point_xyz() { let mut wkb = vec![0x01u8]; // LE wkb.extend_from_slice(&1001u32.to_le_bytes()); // Point + 1000 = XYZ wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); wkb.extend_from_slice(&3.0f64.to_le_bytes()); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT Z (1 2 3)"); } #[test] fn iso_wkb_point_xym() { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&2001u32.to_le_bytes()); // Point + 2000 = XYM wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); wkb.extend_from_slice(&3.0f64.to_le_bytes()); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT M (1 2 3)"); } #[test] fn iso_wkb_point_xyzm() { let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&3001u32.to_le_bytes()); // Point + 3000 = XYZM wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); wkb.extend_from_slice(&3.0f64.to_le_bytes()); wkb.extend_from_slice(&4.0f64.to_le_bytes()); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT ZM (1 2 3 4)"); } // ── Big-endian WKB ─────────────────────────────────────────────────────────── #[test] fn big_endian_point() { let mut wkb = vec![0x00u8]; // BE wkb.extend_from_slice(&1u32.to_be_bytes()); // type = Point XY wkb.extend_from_slice(&1.0f64.to_be_bytes()); wkb.extend_from_slice(&2.0f64.to_be_bytes()); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn big_endian_point_with_srid() { let mut wkb = vec![0x00u8]; // BE wkb.extend_from_slice(&0x2000_0001u32.to_be_bytes()); // type = EWKB SRID | Point XY wkb.extend_from_slice(&4326i32.to_be_bytes()); // SRID = 4326 (big-endian i32) wkb.extend_from_slice(&1.0f64.to_be_bytes()); wkb.extend_from_slice(&2.0f64.to_be_bytes()); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } // ── LineString ─────────────────────────────────────────────────────────────── #[test] fn linestring_xy() { assert_eq!( wkb_to_wkt(&wkb_of("LINESTRING (0 0, 1 1, 2 2)")).unwrap(), "LINESTRING (0 0, 1 1, 2 2)" ); } #[test] fn linestring_xyz() { assert_eq!( wkb_to_wkt(&wkb_of("LINESTRING Z (0 0 0, 1 1 1)")).unwrap(), "LINESTRING Z (0 0 0, 1 1 1)" ); } #[test] fn linestring_empty() { assert_eq!( wkb_to_wkt(&wkb_of("LINESTRING EMPTY")).unwrap(), "LINESTRING EMPTY" ); } #[test] fn linestring_nan_coordinate_outputs_invalid_wkt() { let mut wkb = le_header(2); wkb.extend_from_slice(&1u32.to_le_bytes()); wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&f64::NAN.to_le_bytes()); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "LINESTRING (1 NaN)"); } // ── Polygon ────────────────────────────────────────────────────────────────── #[test] fn polygon_one_ring() { assert_eq!( wkb_to_wkt(&wkb_of("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")).unwrap(), "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" ); } #[test] fn polygon_with_hole() { let wkt = "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 1 9, 9 9, 9 1, 1 1))"; assert_eq!(wkb_to_wkt(&wkb_of(wkt)).unwrap(), wkt); } #[test] fn polygon_empty() { assert_eq!( wkb_to_wkt(&wkb_of("POLYGON EMPTY")).unwrap(), "POLYGON EMPTY" ); } // ── Multi-geometries ───────────────────────────────────────────────────────── #[test] fn multipoint_xy() { assert_eq!( wkb_to_wkt(&wkb_of("MULTIPOINT ((0 0), (1 1))")).unwrap(), "MULTIPOINT ((0 0), (1 1))" ); } #[test] fn multilinestring_xy() { assert_eq!( wkb_to_wkt(&wkb_of("MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))")).unwrap(), "MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))" ); } #[test] fn multipolygon_xy() { let wkt = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))"; assert_eq!(wkb_to_wkt(&wkb_of(wkt)).unwrap(), wkt); } #[test] fn geometrycollection_xy() { let wkt = "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))"; assert_eq!(wkb_to_wkt(&wkb_of(wkt)).unwrap(), wkt); } #[test] fn geometrycollection_xy_parent_allows_heterogeneous_children() { let wkt = "GEOMETRYCOLLECTION (POINT Z (1 2 3), POINT M (4 5 6))"; assert_eq!(wkb_to_wkt(&wkb_of(wkt)).unwrap(), wkt); } #[test] fn geometrycollection_non_xy_parent_requires_matching_child_dimensions() { let wkt = "GEOMETRYCOLLECTION Z (POINT Z (1 2 3))"; assert_eq!(wkb_to_wkt(&wkb_of(wkt)).unwrap(), wkt); let mut wkb = le_header(0x8000_0007); // GeometryCollection Z wkb.extend_from_slice(&1u32.to_le_bytes()); wkb.extend_from_slice(&wkb_of("POINT (1 2)")); assert!(wkb_to_wkt(&wkb).is_err()); } #[test] fn multipoint_empty() { assert_eq!( wkb_to_wkt(&wkb_of("MULTIPOINT EMPTY")).unwrap(), "MULTIPOINT EMPTY" ); } #[test] fn geometrycollection_empty() { assert_eq!( wkb_to_wkt(&wkb_of("GEOMETRYCOLLECTION EMPTY")).unwrap(), "GEOMETRYCOLLECTION EMPTY" ); } // ── Hex convenience ────────────────────────────────────────────────────────── #[test] fn hex_wkb_to_wkt_point() { // Generate hex from known WKT, then verify round-trip via hex helper let hex = wkt_to_hex_wkb("POINT (1 2)").unwrap(); assert_eq!(hex_wkb_to_wkt(&hex, SridMode::Auto).unwrap(), "POINT (1 2)"); } #[test] fn hex_wkb_to_wkt_auto_preserves_srid() { let hex = wkt_to_hex_wkb("SRID=4326;POINT (1 2)").unwrap(); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Auto).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn hex_wkb_to_wkt_strip_removes_srid() { let hex = wkt_to_hex_wkb("SRID=4326;POINT (1 2)").unwrap(); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Strip).unwrap(), "POINT (1 2)" ); } #[test] fn hex_wkb_to_wkt_strip_without_srid_is_noop() { let hex = wkt_to_hex_wkb("POINT (1 2)").unwrap(); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Strip).unwrap(), "POINT (1 2)" ); } #[test] fn hex_wkb_to_wkt_set_adds_srid() { let hex = wkt_to_hex_wkb("POINT (1 2)").unwrap(); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Set(4326)).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn hex_wkb_to_wkt_set_overrides_srid() { let hex = wkt_to_hex_wkb("SRID=4326;POINT (1 2)").unwrap(); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Set(3857)).unwrap(), "SRID=3857;POINT (1 2)" ); } /// SRID=0 is "unknown" per PostGIS convention; Set(0) behaves like Strip. #[test] fn hex_wkb_to_wkt_set_zero_acts_as_strip() { let hex = wkt_to_hex_wkb("SRID=4326;POINT (1 2)").unwrap(); assert_eq!( hex_wkb_to_wkt(&hex, SridMode::Set(0)).unwrap(), "POINT (1 2)" ); } #[test] fn hex_wkb_odd_length_error() { for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(hex_wkb_to_wkt("ABC", mode).is_err()); } } #[test] fn hex_wkb_invalid_chars_error() { for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(hex_wkb_to_wkt("ZZZZ", mode).is_err()); } } #[test] fn hex_wkb_truncated_errors_in_all_srid_modes() { for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(hex_wkb_to_wkt("0101000000", mode).is_err()); } } // ── Error cases ────────────────────────────────────────────────────────────── #[test] fn empty_input_errors() { assert!(wkb_to_wkt(&[]).is_err()); } #[test] fn invalid_byte_order_errors() { assert!(wkb_to_wkt(&[0x02, 0x01, 0x00, 0x00, 0x00]).is_err()); } #[test] fn truncated_wkb_errors() { // Valid header for POINT XY but only one coordinate (Y missing) let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&1u32.to_le_bytes()); wkb.extend_from_slice(&1.0f64.to_le_bytes()); // x only for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(core_wkb_to_wkt(&wkb, mode).is_err()); } } #[test] fn unsupported_type_errors() { // Type code 99 is not a valid geometry type let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&99u32.to_le_bytes()); assert!(wkb_to_wkt(&wkb).is_err()); } #[test] fn truncated_before_type_code_errors() { // Byte-order byte present but only 2 of the 4 type-code bytes available assert!(wkb_to_wkt(&[0x01, 0x01, 0x00]).is_err()); } #[test] fn top_level_trailing_bytes_error() { let mut wkb = wkb_of("POINT (1 2)"); wkb.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]); for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(core_wkb_to_wkt(&wkb, mode).is_err()); } assert!(wkb_to_wkt_split_srid(&wkb).is_err()); let hex = hex::encode_upper(&wkb); for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(hex_wkb_to_wkt(&hex, mode).is_err()); } } #[test] fn geometry_depth_limit_is_enforced_for_wkb() { assert!(wkb_to_wkt(&nested_geometrycollection_wkb(127)).is_ok()); assert!(wkb_to_wkt(&nested_geometrycollection_wkb(128)).is_err()); } // ── MultiPoint edge cases ───────────────────────────────────────────────────── #[test] fn multipoint_with_empty_sub_point() { // A MultiPoint containing one real point and one EMPTY (NaN) sub-point. // The reader should render the EMPTY sub-point as "EMPTY" (no parens). let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&4u32.to_le_bytes()); // MultiPoint wkb.extend_from_slice(&2u32.to_le_bytes()); // 2 members // Member 1: normal point (1, 2) wkb.push(0x01); wkb.extend_from_slice(&1u32.to_le_bytes()); // Point wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); // Member 2: EMPTY point (NaN, NaN) wkb.push(0x01); wkb.extend_from_slice(&1u32.to_le_bytes()); // Point wkb.extend_from_slice(&f64::NAN.to_le_bytes()); wkb.extend_from_slice(&f64::NAN.to_le_bytes()); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "MULTIPOINT ((1 2), EMPTY)"); } #[test] fn multipoint_wrong_subtype_errors() { // A MultiPoint whose sole member is a LineString — must be rejected. let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&4u32.to_le_bytes()); // MultiPoint wkb.extend_from_slice(&1u32.to_le_bytes()); // 1 member wkb.push(0x01); wkb.extend_from_slice(&2u32.to_le_bytes()); // LineString (not Point) wkb.extend_from_slice(&0u32.to_le_bytes()); // 0 points assert!(wkb_to_wkt(&wkb).is_err()); } #[test] fn multipoint_child_dimension_mismatch_errors() { let mut wkb = le_header(0x8000_0004); // MultiPoint Z wkb.extend_from_slice(&1u32.to_le_bytes()); wkb.extend_from_slice(&wkb_of("POINT (1 2)")); assert!(wkb_to_wkt(&wkb).is_err()); } // ── MultiLineString edge cases ──────────────────────────────────────────────── #[test] fn multilinestring_empty_member() { // A MultiLineString whose sole member is an empty (0-point) LineString. let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&5u32.to_le_bytes()); // MultiLineString wkb.extend_from_slice(&1u32.to_le_bytes()); // 1 member wkb.push(0x01); wkb.extend_from_slice(&2u32.to_le_bytes()); // LineString wkb.extend_from_slice(&0u32.to_le_bytes()); // 0 points assert_eq!(wkb_to_wkt(&wkb).unwrap(), "MULTILINESTRING (EMPTY)"); } #[test] fn multilinestring_wrong_subtype_errors() { // A MultiLineString whose member is a Point — must be rejected. let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&5u32.to_le_bytes()); // MultiLineString wkb.extend_from_slice(&1u32.to_le_bytes()); // 1 member wkb.push(0x01); wkb.extend_from_slice(&1u32.to_le_bytes()); // Point (not LineString) wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); assert!(wkb_to_wkt(&wkb).is_err()); } #[test] fn multilinestring_child_dimension_mismatch_errors() { let mut wkb = le_header(5); // MultiLineString XY wkb.extend_from_slice(&1u32.to_le_bytes()); wkb.extend_from_slice(&wkb_of("LINESTRING Z (0 0 0, 1 1 1)")); assert!(wkb_to_wkt(&wkb).is_err()); } // ── MultiPolygon edge cases ─────────────────────────────────────────────────── #[test] fn multipolygon_empty_member() { // A MultiPolygon whose sole member is an empty (0-ring) Polygon. let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&6u32.to_le_bytes()); // MultiPolygon wkb.extend_from_slice(&1u32.to_le_bytes()); // 1 member wkb.push(0x01); wkb.extend_from_slice(&3u32.to_le_bytes()); // Polygon wkb.extend_from_slice(&0u32.to_le_bytes()); // 0 rings assert_eq!(wkb_to_wkt(&wkb).unwrap(), "MULTIPOLYGON (EMPTY)"); } #[test] fn multipolygon_wrong_subtype_errors() { // A MultiPolygon whose member is a Point — must be rejected. let mut wkb = vec![0x01u8]; wkb.extend_from_slice(&6u32.to_le_bytes()); // MultiPolygon wkb.extend_from_slice(&1u32.to_le_bytes()); // 1 member wkb.push(0x01); wkb.extend_from_slice(&1u32.to_le_bytes()); // Point (not Polygon) wkb.extend_from_slice(&1.0f64.to_le_bytes()); wkb.extend_from_slice(&2.0f64.to_le_bytes()); assert!(wkb_to_wkt(&wkb).is_err()); } #[test] fn multipolygon_child_dimension_mismatch_errors() { let mut wkb = le_header(6); // MultiPolygon XY wkb.extend_from_slice(&1u32.to_le_bytes()); wkb.extend_from_slice(&wkb_of("POLYGON Z ((0 0 0, 1 0 0, 1 1 0, 0 0 0))")); assert!(wkb_to_wkt(&wkb).is_err()); } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter/tests/wkt_to_wkb.rs000066400000000000000000000505161521227137000275450ustar00rootroot00000000000000/// WKT → WKB tests. Where possible we verify the exact bytes against known /// PostGIS output. All tests also include a round-trip assertion. use wkb_wkt_converter::{ wkb_to_wkt as core_wkb_to_wkt, wkt_to_hex_wkb as core_wkt_to_hex_wkb, wkt_to_wkb as core_wkt_to_wkb, wkt_to_wkb_split_srid, Result, SridMode, }; fn wkb_to_wkt(wkb: &[u8]) -> Result { core_wkb_to_wkt(wkb, SridMode::Auto) } fn wkt_to_wkb(wkt: &str) -> Result> { core_wkt_to_wkb(wkt, SridMode::Auto) } fn wkt_to_hex_wkb(wkt: &str) -> Result { core_wkt_to_hex_wkb(wkt, SridMode::Auto) } fn roundtrip(wkt: &str) -> String { let wkb = wkt_to_wkb(wkt).unwrap(); wkb_to_wkt(&wkb).unwrap() } // ── Point ──────────────────────────────────────────────────────────────────── #[test] fn point_xy_roundtrip() { assert_eq!(roundtrip("POINT (1 2)"), "POINT (1 2)"); } #[test] fn point_xyz_roundtrip() { assert_eq!(roundtrip("POINT Z (1 2 3)"), "POINT Z (1 2 3)"); } #[test] fn point_xym_roundtrip() { assert_eq!(roundtrip("POINT M (1 2 3)"), "POINT M (1 2 3)"); } #[test] fn point_xyzm_roundtrip() { assert_eq!(roundtrip("POINT ZM (1 2 3 4)"), "POINT ZM (1 2 3 4)"); } #[test] fn point_empty_roundtrip() { assert_eq!(roundtrip("POINT EMPTY"), "POINT EMPTY"); } #[test] fn point_empty_z_roundtrip() { assert_eq!(roundtrip("POINT Z EMPTY"), "POINT Z EMPTY"); } #[test] fn point_negative_coords() { assert_eq!(roundtrip("POINT (-1.5 -2.5)"), "POINT (-1.5 -2.5)"); } #[test] fn point_scientific_notation() { // 1e-4 should survive the round-trip let wkb = wkt_to_wkb("POINT (1e-4 2e-4)").unwrap(); let wkt = wkb_to_wkt(&wkb).unwrap(); assert!(wkt.starts_with("POINT (")); // Verify the values are preserved numerically let wkb2 = wkt_to_wkb(&wkt).unwrap(); assert_eq!(wkb, wkb2); } #[test] fn point_with_srid_embedded() { // EWKT: SRID embedded in WKB output, re-read as EWKT let wkb = wkt_to_wkb("SRID=4326;POINT (1 2)").unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn point_split_srid() { let (wkb, srid) = wkt_to_wkb_split_srid("SRID=4326;POINT (1 2)").unwrap(); assert_eq!(srid, Some(4326)); // The returned bytes must NOT have the SRID flag assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn point_no_srid_split() { let (wkb, srid) = wkt_to_wkb_split_srid("POINT (1 2)").unwrap(); assert_eq!(srid, None); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkt_to_wkb_auto_preserves_srid() { let wkb = core_wkt_to_wkb("SRID=4326;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn wkt_to_wkb_strip_removes_srid() { let wkb = core_wkt_to_wkb("SRID=4326;POINT (1 2)", SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkt_to_wkb_strip_without_srid_is_noop() { let wkb = core_wkt_to_wkb("POINT (1 2)", SridMode::Strip).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkt_to_wkb_set_adds_srid() { let wkb = core_wkt_to_wkb("POINT (1 2)", SridMode::Set(4326)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=4326;POINT (1 2)"); } #[test] fn wkt_to_wkb_set_overrides_srid() { let wkb = core_wkt_to_wkb("SRID=4326;POINT (1 2)", SridMode::Set(3857)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "SRID=3857;POINT (1 2)"); } /// SRID=0 is "unknown" per PostGIS convention; Set(0) behaves like Strip. #[test] fn wkt_to_wkb_set_zero_acts_as_strip() { let wkb = core_wkt_to_wkb("SRID=4326;POINT (1 2)", SridMode::Set(0)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } /// Set(-1) ≤ 0 → treated as unknown; behaves like Strip. #[test] fn wkt_to_wkb_set_negative_one_acts_as_strip() { let wkb = core_wkt_to_wkb("SRID=4326;POINT (1 2)", SridMode::Set(-1)).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } /// EWKT input with SRID=-1 (unknown) → WKB has no SRID embedded. #[test] fn wkt_ewkt_negative_srid_prefix_strips_srid() { let wkb = core_wkt_to_wkb("SRID=-1;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } /// split_srid treats SRID=-1 as absent. #[test] fn wkt_to_wkb_split_srid_returns_none_for_unknown_srid() { let (wkb, srid) = wkt_to_wkb_split_srid("SRID=-1;POINT (1 2)").unwrap(); assert_eq!(srid, None); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } /// SRID=-2147483648 (i32::MIN) is now parseable and normalised to no-SRID. #[test] fn wkt_ewkt_i32_min_srid_strips_srid() { let wkb = core_wkt_to_wkb("SRID=-2147483648;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!(wkb_to_wkt(&wkb).unwrap(), "POINT (1 2)"); } #[test] fn wkt_ewkt_out_of_range_srid_errors() { assert!(core_wkt_to_wkb("SRID=9999999999;POINT (1 2)", SridMode::Auto).is_err()); } #[test] fn wkt_to_hex_wkb_auto_preserves_srid() { let hex = core_wkt_to_hex_wkb("SRID=4326;POINT (1 2)", SridMode::Auto).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(hex).unwrap()).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkt_to_hex_wkb_strip_removes_srid() { let hex = core_wkt_to_hex_wkb("SRID=4326;POINT (1 2)", SridMode::Strip).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(hex).unwrap()).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_to_hex_wkb_strip_without_srid_is_noop() { let hex = core_wkt_to_hex_wkb("POINT (1 2)", SridMode::Strip).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(hex).unwrap()).unwrap(), "POINT (1 2)" ); } #[test] fn wkt_to_hex_wkb_set_adds_srid() { let hex = core_wkt_to_hex_wkb("POINT (1 2)", SridMode::Set(4326)).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(hex).unwrap()).unwrap(), "SRID=4326;POINT (1 2)" ); } #[test] fn wkt_to_hex_wkb_set_overrides_srid() { let hex = core_wkt_to_hex_wkb("SRID=4326;POINT (1 2)", SridMode::Set(3857)).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(hex).unwrap()).unwrap(), "SRID=3857;POINT (1 2)" ); } /// SRID=0 is "unknown" per PostGIS convention; Set(0) behaves like Strip. #[test] fn wkt_to_hex_wkb_set_zero_acts_as_strip() { let hex = core_wkt_to_hex_wkb("SRID=4326;POINT (1 2)", SridMode::Set(0)).unwrap(); assert_eq!( wkb_to_wkt(&hex::decode(hex).unwrap()).unwrap(), "POINT (1 2)" ); } // ── LineString ─────────────────────────────────────────────────────────────── #[test] fn linestring_xy_roundtrip() { assert_eq!( roundtrip("LINESTRING (0 0, 1 1, 2 2)"), "LINESTRING (0 0, 1 1, 2 2)" ); } #[test] fn linestring_xyz_roundtrip() { assert_eq!( roundtrip("LINESTRING Z (0 0 0, 1 1 1)"), "LINESTRING Z (0 0 0, 1 1 1)" ); } #[test] fn linestring_xyzm_roundtrip() { assert_eq!( roundtrip("LINESTRING ZM (0 0 0 1, 1 1 1 2)"), "LINESTRING ZM (0 0 0 1, 1 1 1 2)" ); } #[test] fn linestring_empty_roundtrip() { assert_eq!(roundtrip("LINESTRING EMPTY"), "LINESTRING EMPTY"); } // ── Polygon ────────────────────────────────────────────────────────────────── #[test] fn polygon_one_ring_roundtrip() { assert_eq!( roundtrip("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"), "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))" ); } #[test] fn polygon_with_hole_roundtrip() { let wkt = "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 1 9, 9 9, 9 1, 1 1))"; assert_eq!(roundtrip(wkt), wkt); } #[test] fn polygon_xyz_roundtrip() { let wkt = "POLYGON Z ((0 0 0, 1 0 0, 1 1 0, 0 0 0))"; assert_eq!(roundtrip(wkt), wkt); } #[test] fn polygon_empty_roundtrip() { assert_eq!(roundtrip("POLYGON EMPTY"), "POLYGON EMPTY"); } // ── MultiPoint ─────────────────────────────────────────────────────────────── #[test] fn multipoint_iso_form_roundtrip() { // ISO form: each point has parens assert_eq!( roundtrip("MULTIPOINT ((0 0), (1 1), (2 2))"), "MULTIPOINT ((0 0), (1 1), (2 2))" ); } #[test] fn multipoint_bare_form_roundtrip() { // Bare form (no inner parens) — should also parse, output is ISO form let wkb = wkt_to_wkb("MULTIPOINT (0 0, 1 1)").unwrap(); let wkt = wkb_to_wkt(&wkb).unwrap(); assert_eq!(wkt, "MULTIPOINT ((0 0), (1 1))"); } #[test] fn multipoint_xyz_roundtrip() { assert_eq!( roundtrip("MULTIPOINT Z ((0 0 1), (1 1 2))"), "MULTIPOINT Z ((0 0 1), (1 1 2))" ); } #[test] fn multipoint_empty_roundtrip() { assert_eq!(roundtrip("MULTIPOINT EMPTY"), "MULTIPOINT EMPTY"); } #[test] fn multipoint_empty_members_roundtrip() { assert_eq!(roundtrip("MULTIPOINT (EMPTY)"), "MULTIPOINT (EMPTY)"); assert_eq!( roundtrip("MULTIPOINT (EMPTY, (1 2), EMPTY)"), "MULTIPOINT (EMPTY, (1 2), EMPTY)" ); assert_eq!( roundtrip("MULTIPOINT Z (EMPTY, (1 2 3), EMPTY)"), "MULTIPOINT Z (EMPTY, (1 2 3), EMPTY)" ); } // ── MultiLineString ─────────────────────────────────────────────────────────── #[test] fn multilinestring_xy_roundtrip() { assert_eq!( roundtrip("MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))"), "MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))" ); } #[test] fn multilinestring_z_roundtrip() { assert_eq!( roundtrip("MULTILINESTRING Z ((0 0 1, 1 1 2), (2 2 3, 3 3 4))"), "MULTILINESTRING Z ((0 0 1, 1 1 2), (2 2 3, 3 3 4))" ); } #[test] fn multilinestring_empty_roundtrip() { assert_eq!(roundtrip("MULTILINESTRING EMPTY"), "MULTILINESTRING EMPTY"); } #[test] fn multilinestring_empty_members_roundtrip() { assert_eq!( roundtrip("MULTILINESTRING (EMPTY, (0 0, 1 1), EMPTY)"), "MULTILINESTRING (EMPTY, (0 0, 1 1), EMPTY)" ); assert_eq!( roundtrip("MULTILINESTRING Z (EMPTY, (0 0 0, 1 1 1))"), "MULTILINESTRING Z (EMPTY, (0 0 0, 1 1 1))" ); } // ── MultiPolygon ───────────────────────────────────────────────────────────── #[test] fn multipolygon_xy_roundtrip() { let wkt = "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)), ((2 2, 3 2, 3 3, 2 2)))"; assert_eq!(roundtrip(wkt), wkt); } #[test] fn multipolygon_with_hole_roundtrip() { let wkt = "MULTIPOLYGON (((0 0, 10 0, 10 10, 0 0), (1 1, 1 9, 9 1, 1 1)), ((20 20, 30 20, 30 30, 20 20)))"; assert_eq!(roundtrip(wkt), wkt); } #[test] fn multipolygon_empty_roundtrip() { assert_eq!(roundtrip("MULTIPOLYGON EMPTY"), "MULTIPOLYGON EMPTY"); } #[test] fn multipolygon_empty_members_roundtrip() { assert_eq!( roundtrip("MULTIPOLYGON (EMPTY, ((0 0, 1 0, 1 1, 0 0)), EMPTY)"), "MULTIPOLYGON (EMPTY, ((0 0, 1 0, 1 1, 0 0)), EMPTY)" ); assert_eq!( roundtrip("MULTIPOLYGON Z (EMPTY, ((0 0 0, 1 0 0, 1 1 0, 0 0 0)))"), "MULTIPOLYGON Z (EMPTY, ((0 0 0, 1 0 0, 1 1 0, 0 0 0)))" ); } // ── GeometryCollection ─────────────────────────────────────────────────────── #[test] fn geometrycollection_roundtrip() { let wkt = "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))"; assert_eq!(roundtrip(wkt), wkt); } #[test] fn geometrycollection_nested_roundtrip() { let wkt = "GEOMETRYCOLLECTION (POINT (0 0), POLYGON ((0 0, 1 0, 1 1, 0 0)))"; assert_eq!(roundtrip(wkt), wkt); } #[test] fn geometrycollection_empty_roundtrip() { assert_eq!( roundtrip("GEOMETRYCOLLECTION EMPTY"), "GEOMETRYCOLLECTION EMPTY" ); } #[test] fn geometrycollection_xy_parent_allows_heterogeneous_children() { let wkt = "GEOMETRYCOLLECTION (POINT Z (1 2 3), POINT M (4 5 6))"; assert_eq!(roundtrip(wkt), wkt); } #[test] fn geometrycollection_non_xy_parent_requires_matching_child_dimensions() { let wkt = "GEOMETRYCOLLECTION Z (POINT Z (1 2 3), LINESTRING Z (0 0 0, 1 1 1))"; assert_eq!(roundtrip(wkt), wkt); assert!(wkt_to_wkb("GEOMETRYCOLLECTION Z (POINT (1 2))").is_err()); assert!(wkt_to_wkb("GEOMETRYCOLLECTION Z (POINT M (1 2 3))").is_err()); } // ── Hex convenience ────────────────────────────────────────────────────────── #[test] fn wkt_to_hex_wkb_point() { let hex = wkt_to_hex_wkb("POINT (1 2)").unwrap(); // Must be uppercase hex assert!(hex .chars() .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())); // Must round-trip use wkb_wkt_converter::hex_wkb_to_wkt; assert_eq!(hex_wkb_to_wkt(&hex, SridMode::Auto).unwrap(), "POINT (1 2)"); } // ── Whitespace tolerance ────────────────────────────────────────────────────── #[test] fn extra_whitespace_is_accepted() { assert_eq!(roundtrip(" POINT ( 1 2 ) "), "POINT (1 2)"); } #[test] fn comma_spacing_variants_are_accepted() { // Spacing around commas between coordinate pairs is irrelevant assert_eq!(roundtrip("LINESTRING(1 2,3 4)"), "LINESTRING (1 2, 3 4)"); assert_eq!(roundtrip("LINESTRING(1 2 , 3 4)"), "LINESTRING (1 2, 3 4)"); assert_eq!( roundtrip("POLYGON((0 0,1 0,1 1,0 0))"), "POLYGON ((0 0, 1 0, 1 1, 0 0))" ); } #[test] fn tab_and_newline_whitespace() { assert_eq!(roundtrip("POINT\t(1\t2)"), "POINT (1 2)"); assert_eq!( roundtrip("LINESTRING\n(\n0 0,\n1 1\n)"), "LINESTRING (0 0, 1 1)" ); } // ── Case insensitivity ──────────────────────────────────────────────────────── #[test] fn lowercase_keyword() { assert_eq!(roundtrip("point (1 2)"), "POINT (1 2)"); assert_eq!(roundtrip("linestring (0 0, 1 1)"), "LINESTRING (0 0, 1 1)"); } #[test] fn mixed_case_keyword() { assert_eq!(roundtrip("Point (1 2)"), "POINT (1 2)"); assert_eq!( roundtrip("MultiPolygon (((0 0, 1 0, 1 1, 0 0)))"), "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 0)))" ); } // ── No space before opening parenthesis ────────────────────────────────────── #[test] fn linestring_no_space_before_paren() { assert_eq!(roundtrip("LINESTRING(0 0, 1 1)"), "LINESTRING (0 0, 1 1)"); } #[test] fn polygon_no_space_before_paren() { assert_eq!( roundtrip("POLYGON((0 0, 1 0, 1 1, 0 0))"), "POLYGON ((0 0, 1 0, 1 1, 0 0))" ); } #[test] fn multipoint_no_space_before_paren() { assert_eq!( roundtrip("MULTIPOINT((0 0),(1 1))"), "MULTIPOINT ((0 0), (1 1))" ); } #[test] fn geometrycollection_no_space_before_paren() { assert_eq!( roundtrip("GEOMETRYCOLLECTION(POINT(1 2))"), "GEOMETRYCOLLECTION (POINT (1 2))" ); } // ── Error cases ────────────────────────────────────────────────────────────── #[test] fn empty_string_errors() { assert!(wkt_to_wkb("").is_err()); } #[test] fn unknown_type_errors() { // Starts with an unrecognised letter (T → falls through to catch-all). assert!(wkt_to_wkb("TRIANGLE (0 0, 1 0, 0 1)").is_err()); // Starts with a recognised letter but is not a valid keyword. assert!(wkt_to_wkb("GEOGRAPHY (0 0)").is_err()); // G → not GEOMETRYCOLLECTION assert!(wkt_to_wkb("MESH (0 0)").is_err()); // M → not MULTI* assert!(wkt_to_wkb("LOCUS (0 0)").is_err()); // L → not LINESTRING assert!(wkt_to_wkb("PLACE (0 0)").is_err()); // P → not POLYGON/POINT } #[test] fn missing_closing_paren_errors() { assert!(wkt_to_wkb("POINT (1 2").is_err()); } #[test] fn trailing_content_errors() { assert!(wkt_to_wkb("POINT (1 2) EXTRA").is_err()); } #[test] fn non_ascii_malformed_input_errors_without_panic() { assert!(wkt_to_wkb("☃").is_err()); assert!(wkt_to_wkb("POINT (1 2) ☃").is_err()); assert!(wkt_to_wkb("POINT ☃").is_err()); assert!(wkt_to_wkb("POINTé (1 2)").is_err()); } #[test] fn srid_without_semicolon_errors() { assert!(wkt_to_wkb("SRID=4326 POINT (1 2)").is_err()); } #[test] fn srid_missing_integer_errors() { // SRID= followed immediately by ';' — no integer value present assert!(wkt_to_wkb("SRID=;POINT (1 2)").is_err()); } #[test] fn srid_overflow_errors() { // Value exceeds u32::MAX — triggers the integer-out-of-range error path assert!(wkt_to_wkb("SRID=9999999999;POINT (1 2)").is_err()); } #[test] fn wkt_to_wkb_invalid_input_errors_in_all_srid_modes() { for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(core_wkt_to_wkb("NOT_A_GEOMETRY (1 2)", mode).is_err()); } } #[test] fn wkt_to_wkb_set_validates_invalid_srid_prefix() { assert!(core_wkt_to_wkb("SRID=abc;POINT (1 2)", SridMode::Set(4326)).is_err()); } #[test] fn wkt_to_hex_wkb_invalid_input_errors_in_all_srid_modes() { for mode in [SridMode::Auto, SridMode::Strip, SridMode::Set(4326)] { assert!(core_wkt_to_hex_wkb("NOT_A_GEOMETRY (1 2)", mode).is_err()); } } #[test] fn wkt_to_hex_wkb_set_validates_invalid_srid_prefix() { assert!(core_wkt_to_hex_wkb("SRID=abc;POINT (1 2)", SridMode::Set(4326)).is_err()); } // ── Tokenizer edge cases ────────────────────────────────────────────────────── #[test] fn point_no_space_before_paren() { // No whitespace between keyword and '(' — still valid, no dimension tag assert_eq!(roundtrip("POINT(1 2)"), "POINT (1 2)"); } #[test] fn invalid_coordinate_nonnumeric_errors() { assert!(wkt_to_wkb("POINT (abc 2)").is_err()); } #[test] fn invalid_coordinate_double_dot_errors() { // "1.2.3" is consumed as a token but fails f64 parsing assert!(wkt_to_wkb("POINT (1.2.3 4)").is_err()); } #[test] fn polygon_missing_ring_paren_errors() { // Ring coordinates without inner parentheses — expect_lparen inside read_rings fails assert!(wkt_to_wkb("POLYGON (0 0, 1 0, 1 1, 0 0)").is_err()); } #[test] fn linestring_invalid_separator_errors() { // '+' is neither ',' nor ')' — read_comma_or_rparen fails assert!(wkt_to_wkb("LINESTRING (0 0 + 1 1)").is_err()); } #[test] fn geometry_invalid_body_errors() { // Neither EMPTY nor '(' after the type keyword — read_empty_or_lparen fails assert!(wkt_to_wkb("POINT INVALID").is_err()); } #[test] fn concatenated_keywords_and_empty_are_rejected() { for wkt in [ "POINTEMPTY", "LINESTRINGEMPTY", "POLYGONEMPTY", "MULTIPOINTEMPTY", "MULTILINESTRINGEMPTY", "MULTIPOLYGONEMPTY", "GEOMETRYCOLLECTIONEMPTY", "POINT EMPTYX", "LINESTRING EMPTYX", "POLYGON EMPTYX", "MULTIPOINT (EMPTYX)", "MULTILINESTRING (EMPTYX)", "MULTIPOLYGON (EMPTYX)", "POINTZ", "POINT ZEMPTY", "POINT MEMPTY", "POINT ZMEMPTY", ] { assert!(wkt_to_wkb(wkt).is_err(), "{wkt} should be rejected"); } } #[test] fn non_finite_wkt_coordinates_are_rejected() { assert!(wkt_to_wkb("POINT (1e9999 2)").is_err()); assert!(wkt_to_wkb("LINESTRING (0 0, 1 1e9999)").is_err()); } fn nested_geometrycollection_wkt(collection_depth: usize) -> String { let mut wkt = String::new(); for _ in 0..collection_depth { wkt.push_str("GEOMETRYCOLLECTION ("); } wkt.push_str("POINT (1 2)"); for _ in 0..collection_depth { wkt.push(')'); } wkt } #[test] fn geometry_depth_limit_is_enforced_for_wkt() { assert!(wkt_to_wkb(&nested_geometrycollection_wkt(127)).is_ok()); assert!(wkt_to_wkb(&nested_geometrycollection_wkt(128)).is_err()); } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter_py/000077500000000000000000000000001521227137000243645ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter_py/Cargo.toml000066400000000000000000000007101521227137000263120ustar00rootroot00000000000000[package] name = "wkb_wkt_converter_py" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true description = "Python bindings for wkb_wkt_converter" publish = false [lib] name = "wkb_wkt_converter_py" crate-type = ["cdylib"] [dependencies] wkb_wkt_converter = { path = "../wkb_wkt_converter" } pyo3 = { version = "0.28", features = ["extension-module"] } adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter_py/src/000077500000000000000000000000001521227137000251535ustar00rootroot00000000000000adrien-berchet-WKB_WKT_Converter-f9310c3/wkb_wkt_converter_py/src/lib.rs000066400000000000000000000466731521227137000263070ustar00rootroot00000000000000// PyO3's #[pyfunction] macro expansion triggers this lint as a false positive. #![allow(clippy::useless_conversion)] use pyo3::buffer::PyUntypedBuffer; use pyo3::exceptions::{PyAttributeError, PyBufferError, PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::types::{PyBool, PyBytes, PyBytesMethods, PyInt, PyString}; use wkb_wkt_converter as core; fn to_py_err(e: core::Error) -> PyErr { PyValueError::new_err(e.to_string()) } fn srid_range_error() -> PyErr { PyValueError::new_err( "srid is out of the allowed range: must fit in a 32-bit integer \ (-2147483648 to 2147483647)", ) } #[derive(Clone, Copy)] enum ExplicitSridMode { Strip, Set(i32), } impl From for core::SridMode { fn from(mode: ExplicitSridMode) -> Self { match mode { ExplicitSridMode::Strip => core::SridMode::Strip, ExplicitSridMode::Set(n) => core::SridMode::Set(n), } } } fn parse_srid_value( val: &Bound<'_, PyAny>, true_error: &'static str, type_error: &'static str, ) -> PyResult { if val.is_instance_of::() { if val.extract::()? { Err(PyValueError::new_err(true_error)) } else { Ok(ExplicitSridMode::Strip) } } else if val.is_instance_of::() { parse_srid_int(val, type_error) } else { let index_method = match val.getattr("__index__") { Ok(index_method) => index_method, Err(err) if err.is_instance_of::(val.py()) => { return Err(PyValueError::new_err(type_error)); } Err(err) => return Err(err), }; let index = index_method.call0()?; parse_srid_int(&index, type_error) } } fn parse_srid_int(val: &Bound<'_, PyAny>, type_error: &'static str) -> PyResult { if val.is_instance_of::() || !val.is_instance_of::() { return Err(PyValueError::new_err(type_error)); } val.extract::() .map(ExplicitSridMode::Set) .map_err(|_| srid_range_error()) } /// Maps the Python `srid` argument (`None`, `False`, or an integer) to /// a [`core::SridMode`]. `True` is rejected with a clear error message. fn parse_srid_arg(val: Option>) -> PyResult { match val { None => Ok(core::SridMode::Auto), Some(v) => parse_srid_value( &v, "srid=True is not valid; pass an integer SRID, False, or None", "srid must be None, False, or an integer", ) .map(Into::into), } } /// Maps the required Python `srid` argument for ``to_ewkb_header``. /// /// `False` strips the SRID, `True` is rejected to avoid ambiguity, and an /// integer embeds or replaces the SRID. fn parse_header_srid_arg(val: Bound<'_, PyAny>) -> PyResult { parse_srid_value( &val, "srid=True is not valid; pass an integer SRID or False", "srid must be False or an integer", ) } fn apply_header_srid_arg(wkb: &[u8], srid: ExplicitSridMode) -> Result, core::Error> { match srid { ExplicitSridMode::Strip => core::wkb_strip_srid(wkb), ExplicitSridMode::Set(n) => core::wkb_set_srid(wkb, n), } } fn with_wkb_buffer( input: &Bound<'_, PyAny>, arg_name: &str, f: impl FnOnce(&[u8]) -> PyResult, ) -> PyResult { if let Ok(bytes) = input.cast::() { return f(bytes.as_bytes()); } let buffer = match PyUntypedBuffer::get(input) { Ok(buffer) => buffer, Err(err) if err.is_instance_of::(input.py()) => { return Err(PyBufferError::new_err(format!( "{arg_name} must be a contiguous one-byte buffer" ))); } Err(err) => return Err(err), }; if buffer.item_size() != 1 || !buffer.is_c_contiguous() { return Err(PyBufferError::new_err(format!( "{arg_name} must be a contiguous one-byte buffer" ))); } let len = buffer.len_bytes(); let borrowed: &[u8] = if len == 0 { &[] } else { // SAFETY: `PyUntypedBuffer::get` keeps the exporter alive for the // lifetime of `buffer`, and the checks above guarantee a C-contiguous // memory region containing exactly `len` one-byte elements. The slice // is borrowed only for the callback below, while `buffer` remains in // scope. Callers keep Python argument extraction outside this borrowed // window. The GIL is held throughout, so no concurrent modification is // possible. Callbacks may create new Python objects (e.g. PyBytes) as // long as they do not release the GIL or call Python code that could // modify or invalidate the exported buffer. // For performance, writable exporters are borrowed too; the public API // documents that mutating the buffer during conversion is unsupported. unsafe { std::slice::from_raw_parts(buffer.buf_ptr().cast::(), len) } }; f(borrowed) } /// Converts WKB/EWKB bytes-like input to a WKT/EWKT string. /// /// ``wkb`` may be ``bytes``, ``bytearray``, ``memoryview``, or another /// C-contiguous one-byte buffer object. It is always treated as raw WKB/EWKB. /// For performance, bytes-like inputs may be borrowed directly without /// copying; mutating a writable buffer during conversion is unsupported and may /// produce invalid or inconsistent results. /// /// *srid* controls SRID handling in the output: /// - ``None`` (default): mirror the input — ``SRID=N;`` prefix kept if present, absent if not. /// - ``False``: always strip the ``SRID=N;`` prefix from the output. /// - integer: always prepend ``SRID=N;``, overriding whatever the input contains. #[pyfunction] #[pyo3(signature = (wkb, srid=None))] fn wkb_to_wkt(wkb: Bound<'_, PyAny>, srid: Option>) -> PyResult { let srid = parse_srid_arg(srid)?; with_wkb_buffer(&wkb, "wkb", |wkb| { core::wkb_to_wkt(wkb, srid).map_err(to_py_err) }) } /// Converts WKB/EWKB bytes-like input to a WKT string and returns the SRID separately. /// The returned WKT string does not contain a `SRID=N;` prefix. /// /// For performance, bytes-like inputs may be borrowed directly without /// copying; mutating a writable buffer during conversion is unsupported and may /// produce invalid or inconsistent results. #[pyfunction] fn wkb_to_wkt_split_srid(wkb: Bound<'_, PyAny>) -> PyResult<(String, Option)> { with_wkb_buffer(&wkb, "wkb", |wkb| { core::wkb_to_wkt_split_srid(wkb).map_err(to_py_err) }) } /// Converts a WKT/EWKT string to EWKB bytes. /// /// *srid* controls SRID handling in the output: /// - ``None`` (default): mirror the input — SRID is kept if present, absent if not. /// - ``False``: always strip the SRID from the output. /// - integer: always embed this SRID, overriding whatever the input contains. #[pyfunction] #[pyo3(signature = (wkt, srid=None))] fn wkt_to_wkb(wkt: &str, srid: Option>) -> PyResult> { core::wkt_to_wkb(wkt, parse_srid_arg(srid)?).map_err(to_py_err) } /// Converts a WKT/EWKT string to EWKB bytes and returns the SRID separately. /// The SRID is not embedded in the returned bytes. #[pyfunction] fn wkt_to_wkb_split_srid(wkt: &str) -> PyResult<(Vec, Option)> { core::wkt_to_wkb_split_srid(wkt).map_err(to_py_err) } /// Converts a WKT/EWKT string to an uppercase hex-encoded EWKB string. /// /// *srid* controls SRID handling in the output: /// - ``None`` (default): mirror the input — SRID is kept if present, absent if not. /// - ``False``: always strip the SRID from the output. /// - integer: always embed this SRID, overriding whatever the input contains. #[pyfunction] #[pyo3(signature = (wkt, srid=None))] fn wkt_to_hex_wkb(wkt: &str, srid: Option>) -> PyResult { core::wkt_to_hex_wkb(wkt, parse_srid_arg(srid)?).map_err(to_py_err) } /// Converts a hex-encoded WKB/EWKB string to a WKT/EWKT string. /// /// *srid* controls SRID handling in the output: /// - ``None`` (default): mirror the input — ``SRID=N;`` prefix kept if present, absent if not. /// - ``False``: always strip the ``SRID=N;`` prefix from the output. /// - integer: always prepend ``SRID=N;``, overriding whatever the input contains. #[pyfunction] #[pyo3(signature = (hex_wkb, srid=None))] fn hex_wkb_to_wkt(hex_wkb: &str, srid: Option>) -> PyResult { core::hex_wkb_to_wkt(hex_wkb, parse_srid_arg(srid)?).map_err(to_py_err) } /// Converts a WKT/EWKT string, a hex-encoded WKB/EWKB string, or bytes-like /// WKB/EWKB input to an uppercase hex-encoded EWKB string. /// The string input format is detected automatically: non-empty, even-length /// all-hex text is treated as hex WKB; anything else is treated as WKT. /// Bytes-like input is always treated as raw WKB/EWKB. /// With ``srid=None``, hex input is validated as hex text and uppercased /// without WKB structure validation, and bytes-like input is hex-encoded /// without WKB structure validation. /// For performance, bytes-like inputs may be borrowed directly without /// copying; mutating a writable buffer during conversion is unsupported and may /// produce invalid or inconsistent results. /// /// *srid* controls SRID handling in the output — see ``to_wkb``. #[pyfunction] #[pyo3(signature = (source, srid=None))] fn to_hex_wkb(source: Bound<'_, PyAny>, srid: Option>) -> PyResult { let srid = parse_srid_arg(srid)?; if let Ok(text) = source.cast::() { return core::to_hex_wkb(core::Input::Text(text.to_str()?), srid).map_err(to_py_err); } with_wkb_buffer(&source, "source", |wkb| { core::to_hex_wkb(core::Input::Wkb(wkb), srid).map_err(to_py_err) }) } /// Converts a WKT/EWKT string, a hex-encoded WKB/EWKB string, or bytes-like /// WKB/EWKB input to WKB bytes. /// The string input format is detected automatically: non-empty, even-length /// all-hex text is treated as hex WKB; anything else is treated as WKT. /// Bytes-like input is always treated as raw WKB/EWKB. /// /// *srid* controls SRID handling in the output: /// - ``None`` (default): mirror the input — SRID is kept if present, absent if not. /// Hex WKB and bytes-like WKB bytes are returned as-is without WKB structure validation. /// - ``False``: always strip the SRID from the output. /// - integer: always embed this SRID, overriding whatever the input contains. /// /// For performance, bytes-like inputs may be borrowed directly without /// copying; mutating a writable buffer during conversion is unsupported and may /// produce invalid or inconsistent results. /// /// For ``False`` and integer SRIDs, simple Point, LineString, and Polygon /// EWKB/WKB hex or bytes-like input in either byte order is patched at the /// top-level header without scanning the geometry body. Malformed coordinate /// bodies or trailing bytes in those simple fast paths can pass through as /// invalid output bytes. #[pyfunction] #[pyo3(signature = (source, srid=None))] fn to_wkb(source: Bound<'_, PyAny>, srid: Option>) -> PyResult> { let srid = parse_srid_arg(srid)?; if let Ok(text) = source.cast::() { return core::to_wkb(core::Input::Text(text.to_str()?), srid).map_err(to_py_err); } with_wkb_buffer(&source, "source", |wkb| { core::to_wkb(core::Input::Wkb(wkb), srid).map_err(to_py_err) }) } /// Converts a WKT/EWKT string, a hex-encoded WKB/EWKB string, or bytes-like /// WKB/EWKB input to a WKT string. /// The string input format is detected automatically: non-empty, even-length /// all-hex text is treated as hex WKB; anything else is treated as WKT. /// Bytes-like input is always treated as raw WKB/EWKB. /// /// *srid* controls SRID handling in the output: /// - ``None`` (default): mirror the input — ``SRID=N;`` prefix kept if present, absent if not. /// - ``False``: always strip the ``SRID=N;`` prefix from the output. /// - integer: always prepend ``SRID=N;``, overriding whatever the input contains. /// /// *normalize_wkt* (default ``False``): when ``True``, WKT input is normalised /// (canonical casing, spacing, coordinate formatting) via a round-trip through /// WKB. When ``False``, only the SRID prefix is adjusted — **no validation is /// performed and malformed WKT is returned without raising an error.** /// Leading/trailing whitespace is always stripped regardless of this flag. /// Hex WKB and bytes-like WKB input are always decoded to normalised WKT /// regardless of this flag. /// Odd-length all-hex input is not hex WKB; with ``normalize_wkt=False`` it /// follows the same unvalidated WKT fast path. /// For performance, bytes-like inputs may be borrowed directly without /// copying; mutating a writable buffer during conversion is unsupported and may /// produce invalid or inconsistent results. #[pyfunction] #[pyo3(signature = (source, srid=None, normalize_wkt=false))] fn to_wkt( source: Bound<'_, PyAny>, srid: Option>, normalize_wkt: bool, ) -> PyResult { let srid = parse_srid_arg(srid)?; if let Ok(text) = source.cast::() { return core::to_wkt(core::Input::Text(text.to_str()?), srid, normalize_wkt) .map_err(to_py_err); } with_wkb_buffer(&source, "source", |wkb| { core::to_wkt(core::Input::Wkb(wkb), srid, normalize_wkt).map_err(to_py_err) }) } /// Returns the SRID embedded in the top-level WKB/EWKB header, or ``None`` /// if no SRID is present. /// /// ``source`` may be a bytes-like WKB/EWKB value or a hex-encoded WKB/EWKB /// string. For bytes-like input it is borrowed without copying. /// /// For any byte order and any geometry type with only the three canonical EWKB /// flag bits (Z, M, SRID) in the high type-word half, the SRID is read /// directly from the 9-byte header without parsing the geometry body. This /// covers EWKB types 1–7, plain WKB, and ISO-dimensional WKB (which has no /// SRID flag, so ``None`` is returned immediately without a full parse). /// Inputs shorter than the 5-byte WKB header, inputs with unknown flag bits, /// and inputs with an invalid byte-order marker fall back to a full parse. /// /// Note: this function reads only the top-level header and does not validate /// the base geometry type code. An input whose header has valid EWKB flag bits /// but an unsupported geometry type (e.g. type code 99) will have its SRID /// returned without error; attempting to convert such bytes to WKT will still /// fail. This is intentional — extracting the SRID from a partially valid /// header can be useful even when the full geometry is unrecognised. /// /// Pass ``include_unknown=True`` to distinguish an EWKB header that has the /// SRID flag set with an unknown/non-positive raw SRID from a header with no /// SRID flag. With this opt-in mode, raw SRID ``0`` returns ``0`` and raw /// ``0xffffffff`` returns ``-1`` according to the signed i32 SRID model. #[pyfunction(signature = (source, *, include_unknown=false))] fn wkb_header_srid(source: Bound<'_, PyAny>, include_unknown: bool) -> PyResult> { if let Ok(text) = source.cast::() { let bytes = core::decode_hex(text.to_str()?).map_err(to_py_err)?; return if include_unknown { core::wkb_header_srid_including_unknown(&bytes).map_err(to_py_err) } else { core::wkb_header_srid(&bytes).map_err(to_py_err) }; } with_wkb_buffer(&source, "source", |wkb| { if include_unknown { core::wkb_header_srid_including_unknown(wkb).map_err(to_py_err) } else { core::wkb_header_srid(wkb).map_err(to_py_err) } }) } /// Strips the top-level SRID flag and SRID field from WKB/EWKB input. /// /// ``source`` may be a bytes-like WKB/EWKB value or a hex-encoded WKB/EWKB /// string. The return type mirrors the input type: binary input returns /// ``bytes``, hex-string input returns an uppercase hex string. /// /// For simple Point, LineString, or Polygon EWKB/WKB headers in either byte /// order, the header is rewritten without parsing the geometry body. /// Collection types, ISO-dimensional inputs, and non-canonical headers fall /// back to a full WKB→WKT→WKB round-trip which normalises the representation. #[pyfunction] fn to_wkb_no_srid_header(source: Bound<'_, PyAny>) -> PyResult> { let py = source.py(); if let Ok(text) = source.cast::() { let bytes = core::decode_hex(text.to_str()?).map_err(to_py_err)?; let result = core::wkb_strip_srid(&bytes).map_err(to_py_err)?; let hex = core::encode_hex(&result); return Ok(PyString::new(py, &hex).into_any().unbind()); } with_wkb_buffer(&source, "source", |wkb| { let (len, writer) = core::wkb_strip_srid_writer(wkb).map_err(to_py_err)?; PyBytes::new_with(py, len, |buf| { writer.write_into(buf); Ok(()) }) .map(|b| b.into_any().unbind()) }) } /// Embeds or replaces the SRID in WKB/EWKB input. /// /// ``source`` may be a bytes-like WKB/EWKB value or a hex-encoded WKB/EWKB /// string. The return type mirrors the input type: binary input returns /// ``bytes``, hex-string input returns an uppercase hex string. /// /// An existing embedded SRID is replaced rather than doubled. Pass ``False`` /// to strip the SRID; pass an integer to embed or replace it. ``True`` is /// rejected to avoid ambiguity. Integer SRID values ≤ 0 strip the SRID per /// the PostGIS convention (``SRID_IS_UNKNOWN(x) ((int)x<=0)``), identical to /// ``to_wkb_no_srid_header``. /// /// For simple Point, LineString, or Polygon EWKB/WKB headers in either byte /// order, the header is rewritten without parsing the geometry body. /// Collection types, ISO-dimensional inputs, and non-canonical headers fall /// back to a full WKB→WKT→WKB round-trip which normalises the representation. #[pyfunction] fn to_ewkb_header(source: Bound<'_, PyAny>, srid: Bound<'_, PyAny>) -> PyResult> { let py = source.py(); let srid = parse_header_srid_arg(srid)?; if let Ok(text) = source.cast::() { let bytes = core::decode_hex(text.to_str()?).map_err(to_py_err)?; let result = apply_header_srid_arg(&bytes, srid).map_err(to_py_err)?; let hex = core::encode_hex(&result); return Ok(PyString::new(py, &hex).into_any().unbind()); } with_wkb_buffer(&source, "source", |wkb| { let (len, writer) = match srid { ExplicitSridMode::Strip => core::wkb_strip_srid_writer(wkb), ExplicitSridMode::Set(n) => core::wkb_set_srid_writer(wkb, n), } .map_err(to_py_err)?; PyBytes::new_with(py, len, |buf| { writer.write_into(buf); Ok(()) }) .map(|b| b.into_any().unbind()) }) } #[pymodule(name = "wkb_wkt_converter")] fn wkb_wkt_converter_py(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(wkb_to_wkt, m)?)?; m.add_function(wrap_pyfunction!(wkb_to_wkt_split_srid, m)?)?; m.add_function(wrap_pyfunction!(wkt_to_wkb, m)?)?; m.add_function(wrap_pyfunction!(wkt_to_wkb_split_srid, m)?)?; m.add_function(wrap_pyfunction!(wkt_to_hex_wkb, m)?)?; m.add_function(wrap_pyfunction!(hex_wkb_to_wkt, m)?)?; m.add_function(wrap_pyfunction!(to_wkb, m)?)?; m.add_function(wrap_pyfunction!(to_wkt, m)?)?; m.add_function(wrap_pyfunction!(to_hex_wkb, m)?)?; m.add_function(wrap_pyfunction!(wkb_header_srid, m)?)?; m.add_function(wrap_pyfunction!(to_wkb_no_srid_header, m)?)?; m.add_function(wrap_pyfunction!(to_ewkb_header, m)?)?; Ok(()) }