diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a253667..2d666fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,13 @@ on: pull_request: workflow_dispatch: +# One run per branch/PR: a new push cancels the superseded run instead of +# queueing another full build of code nobody is waiting on. Runs on `main` +# and tags keep their own group and are never cancelled by a later push. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: ubuntu-26-04: name: Build & test (Ubuntu 26.04, distro mapnik) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..60487e2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,133 @@ +name: release + +# Tagging `v*` publishes a release with a static `render` for each +# architecture. `workflow_dispatch` builds the same matrix without publishing, +# which is the only way to find out that an arch broke before a tag is already +# cut and public. +# +# Linux/musl only: a fully static binary needs musl, and macOS can't link +# libSystem statically (see the static-render section of the README). +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + publish: + description: "Publish a GitHub release (off = build and upload artifacts only)" + type: boolean + default: false + +permissions: + contents: read + +jobs: + build: + name: ${{ matrix.platform }} + strategy: + # One platform failing shouldn't hide whether the others are healthy. + fail-fast: false + matrix: + include: + - platform: x86_64-linux-musl + runner: ubuntu-24.04 + - platform: aarch64-linux-musl + runner: ubuntu-24.04-arm + # macOS can't link libSystem statically, so this one bundles mapnik + # and every third-party library but leaves macOS' own dylibs dynamic. + - platform: aarch64-apple-darwin + runner: macos-14 + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + + # A tag's archive is named after the tag, so the two can't drift; a + # dispatch run falls back to the project version in CMakeLists.txt. + - name: Resolve version + id: version + run: | + set -euo pipefail + project="$(sed -n 's/^project(osmflat-mapnik-plugin VERSION \([^ )]*\).*/\1/p' CMakeLists.txt)" + if [ "${GITHUB_REF_TYPE}" = tag ]; then + version="${GITHUB_REF_NAME#v}" + if [ "$version" != "$project" ]; then + echo "tag $GITHUB_REF_NAME does not match project VERSION $project" >&2 + exit 1 + fi + else + version="$project" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # No build cache here, unlike the CI workflow: a published binary should + # come from a clean build of the tagged tree, not a tree carried over + # from an earlier run. + - name: Build, test, and package (linux/musl) + if: runner.os == 'Linux' + run: | + docker run --rm -e OSMFLAT_RENDER_VERSION -v "$PWD:/src" -w /src alpine:3.22 \ + sh scripts/build-static-render.sh + env: + OSMFLAT_RENDER_VERSION: ${{ steps.version.outputs.version }} + + - name: Build, test, and package (macOS) + if: runner.os == 'macOS' + run: sh scripts/build-static-render-macos.sh + env: + OSMFLAT_RENDER_VERSION: ${{ steps.version.outputs.version }} + + # Catches an archive that built but can't run on a machine with none of + # the build dependencies present -- the whole point of a static binary. + # (macOS is covered by the otool check inside its build script, since + # there's no dependency-free macOS sandbox to unpack into here.) + - name: Smoke test the packaged archive + if: runner.os == 'Linux' + run: | + set -euo pipefail + docker run --rm -v "$PWD:/src:ro" alpine:3.22 sh -c ' + set -eu + cd /tmp && tar xzf /src/dist/*.tar.gz && cd osmflat-render-*-linux-musl + ./render /no/plugins /src/test/style-relations.xml \ + /src/test/fixtures/baarle-hertog.osm.flat /tmp/out.png \ + 4.75 51.38 5.02 51.49 500 500 + test -s /tmp/out.png' + + - uses: actions/upload-artifact@v4 + with: + name: osmflat-render-${{ matrix.platform }} + path: dist/*.tar.gz + if-no-files-found: error + + release: + name: publish + needs: build + # Only a tag publishes. A dispatch run stops after `build`, leaving the + # archives downloadable from the run for inspection. + if: startsWith(github.ref, 'refs/tags/v') || inputs.publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + # One checksum file over every archive, so a download can be verified + # without trusting the transport. + - name: Checksums + run: | + cd artifacts + sha256sum * > SHA256SUMS + cat SHA256SUMS + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="${GITHUB_REF_NAME}" + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "$tag" \ + --generate-notes \ + artifacts/* diff --git a/.github/workflows/static-render.yml b/.github/workflows/static-render.yml new file mode 100644 index 0000000..338abad --- /dev/null +++ b/.github/workflows/static-render.yml @@ -0,0 +1,81 @@ +name: Static render + +# Builds a self-contained `render`: Mapnik compiled from source as a static +# library with the osmflat datasource built in, linked fully static against +# musl. No libmapnik, osmflat.input, or system fonts needed at runtime. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# One run per branch/PR: a new push cancels the superseded run instead of +# queueing another full build of code nobody is waiting on. Runs on `main` +# and tags keep their own group and are never cancelled by a later push. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Static render (${{ matrix.platform }}) + strategy: + fail-fast: false + matrix: + include: + - platform: x86_64-linux-musl + runner: ubuntu-24.04 + - platform: aarch64-linux-musl + runner: ubuntu-24.04-arm + - platform: aarch64-apple-darwin + runner: macos-14 + runs-on: ${{ matrix.runner }} + + steps: + - name: Check out + uses: actions/checkout@v4 + + # Mapnik is ~175 translation units and dominates the build (~16 min cold + # on x86_64), so cache the whole build tree: its fetched source + objects, + # the cargo registry, and the downloaded apk packages all live there. + # ninja/cargo decide what to redo, so a source change still rebuilds + # exactly what it must. The key is unique per run (caches are immutable) + # and restore-keys fall back to the newest cache for this arch; a new + # mapnik tag or changed build plumbing starts a fresh lineage. + - name: Cache static build tree + uses: actions/cache@v4 + with: + path: build-static + key: static-render-${{ matrix.platform }}-${{ hashFiles('cmake/**', 'scripts/*.sh', 'CMakeLists.txt') }}-${{ github.run_id }} + restore-keys: | + static-render-${{ matrix.platform }}-${{ hashFiles('cmake/**', 'scripts/*.sh', 'CMakeLists.txt') }}- + static-render-${{ matrix.platform }}- + + # Run the whole build in Alpine via `docker run` rather than a job-level + # `container:` -- GitHub's JavaScript actions don't run inside Alpine + # containers on arm64 runners, and this is exactly what a local + # `podman run` executes too. + - name: Build, test, and package (linux/musl) + if: runner.os == 'Linux' + run: | + docker run --rm -v "$PWD:/src" -w /src alpine:3.22 \ + sh scripts/build-static-render.sh + + - name: Build, test, and package (macOS) + if: runner.os == 'macOS' + run: sh scripts/build-static-render-macos.sh + + # The container builds as root (apk needs it), and some of what cargo + # leaves behind isn't readable by the runner user -- which makes the + # post-job cache save fail on `tar: ... Permission denied`. + - name: Hand the build tree back to the runner user + if: runner.os == 'Linux' + run: sudo chown -R "$(id -u):$(id -g)" build-static dist + + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: osmflat-render-${{ matrix.platform }} + path: dist/*.tar.gz + if-no-files-found: error diff --git a/.gitignore b/.gitignore index ee3d5a5..c48fc8e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ # Render smoke-test output *.png /compile_commands.json +/build-static/ +/dist/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cdf036..56536c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,15 @@ corrosion_import_crate( PROFILE release ) +# --- Static render (cmake -DOSMFLAT_STATIC_RENDER=ON) ------------------------ +# Builds Mapnik from source with osmflat compiled in, and a self-contained +# `render` linked against it -- instead of the osmflat.input module below. +option(OSMFLAT_STATIC_RENDER "Build a static Mapnik with osmflat built in, and link render against it" OFF) +if(OSMFLAT_STATIC_RENDER) + include(cmake/StaticRender.cmake) + return() +endif() + # --- Mapnik ------------------------------------------------------------------ find_package(PkgConfig REQUIRED) # Mapnik 4 is required: its API returns std::optional where 3.x returned diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..0c840ea --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) 2026 Boyd Johnson + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index a53233c..8006c34 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,76 @@ there means building mapnik from source. crate, so a clone of this repo builds on its own — no sibling checkouts — but the first build needs network access for cargo to fetch them. +## Static, self-contained `render` + +`-DOSMFLAT_STATIC_RENDER=ON` builds mapnik from source (v4.2.2 by default) as a +**static** library with the osmflat datasource compiled in as a built-in +plugin, and links `render` against it. The result loads nothing at runtime — no +`libmapnik`, no `osmflat.input`, no system fonts — so it can be copied to a +machine that has never heard of mapnik: + +```sh +cmake -S . -B build-static -DCMAKE_BUILD_TYPE=Release \ + -DOSMFLAT_STATIC_RENDER=ON -DOSMFLAT_FULLY_STATIC=ON +cmake --build build-static --target render +``` + +`OSMFLAT_FULLY_STATIC=ON` adds `-static` (musl only: libc goes in too). On +macOS, leave it off -- libSystem can't be linked statically, so mapnik and +every third-party library go in but macOS' own dylibs and frameworks stay +dynamic; `scripts/build-static-render-macos.sh` builds that and fails if +`otool -L` shows anything outside `/usr/lib` or `/System`. Two macOS +wrinkles it handles: Homebrew's `libharfbuzz.a` needs graphite2, which has no +static archive, so harfbuzz is built from source with just freetype and +CoreText; and icu4c/zlib/bzip2 are keg-only, so their prefixes are passed +explicitly. The +`` argument stays in the CLI but is ignored — loading a `.input` +module would pull in a second, shared mapnik. + +Mapnik's static-plugin table is compile-time, so +`cmake/static-mapnik/patch-mapnik.cmake` edits the fetched mapnik tree: it adds +`plugins/input/osmflat`, links it into `libmapnik`, registers it in +`datasource_cache_static.cpp`, and teaches `projection` to accept +`+proj=longlat ...` (see below). Every edit anchors on an exact upstream string +and fails loudly if mapnik moved it. + +Trimmed to what `render` needs: AGG + PNG, freetype/harfbuzz/ICU for text. No +cairo, PROJ, grid/SVG renderers, or stock input plugins. Dropping PROJ is what +keeps the binary free of a 9 MB `proj.db`, but mapnik then only knows +`epsg:4326` / `epsg:3857` by name — and, more subtly, classifies `epsg:4326` as +*geographic*, which scales `scale_denominator` by ~111319 versus what PROJ +reports for the equivalent `+proj=longlat +datum=WGS84 +no_defs`. Since every +style here is tuned against the degree-based numbers (`0.1` ≈ neighborhood), +the patch makes a PROJ-less mapnik classify `+proj=longlat` exactly as PROJ +does, so styles render identically either way. Other proj4 strings still throw. + +Fonts: `$MAPNIK_FONT_DIR` if set, else `fonts/` beside the binary (which is how +the release tarball ships DejaVu). + +`scripts/build-static-render.sh` does the whole thing in Alpine — installs the +static dependency archives, builds, runs ctest, and packages +`dist/osmflat-render---linux-musl.tar.gz`: + +```sh +podman run --rm -v "$PWD:/src" -w /src alpine:3.22 sh scripts/build-static-render.sh +``` + +`.github/workflows/static-render.yml` runs that same script under `docker run` +for x86_64 and aarch64, plus the macOS script on `macos-14`, and uploads all +three tarballs, caching the build tree +(mapnik is ~16 min cold, ~1 min warm). `.github/workflows/release.yml` builds +the same three archives from a clean tree on a `v*` tag — the tag has to match +`project(... VERSION)` — smoke-tests each unpacked archive on a bare alpine +image, and publishes them with a `SHA256SUMS`. `workflow_dispatch` on that +workflow builds without publishing, to check an arch before cutting the tag. + +Each archive carries `render`, `fonts/`, mapnik's `LICENSE.mapnik`, and a +`NOTICE` naming every statically linked library and how to relink. + +**Licensing:** mapnik is LGPL-2.1, so a distributed binary with mapnik linked +in must let recipients relink it against a modified mapnik — publishing these +sources plus the build scripts is what covers that. + ## Datasource parameters | param | required | values | meaning | @@ -450,3 +520,18 @@ Two sidecar sources can back this, checked in order: See `osmflat-ext`'s README for both build sides and `osmflat_ext::coastline` / `osmflat_ext::land_polygons`'s module docs for the respective algorithms. + +## License + +MIT ([LICENSE-MIT](LICENSE-MIT) or ). + +MIT rather than the usual Rust-ecosystem MIT/Apache-2.0 dual license because +the static `render` links LGPL-2.1 mapnik, and Apache-2.0 is considered +incompatible with LGPL-2.1 for a combined work. + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you shall be licensed as above, +without any additional terms or conditions. + +The statically linked `render` archives are a separate case -- see +"Static, self-contained `render`" above. diff --git a/cmake/StaticRender.cmake b/cmake/StaticRender.cmake new file mode 100644 index 0000000..2ddf612 --- /dev/null +++ b/cmake/StaticRender.cmake @@ -0,0 +1,225 @@ +# OSMFLAT_STATIC_RENDER=ON: build Mapnik from source as a static library with +# the osmflat datasource compiled in as a built-in plugin, and link `render` +# against it. The result has no libmapnik / osmflat.input to find at runtime. +# +# Included from the top-level CMakeLists after the Rust crate is imported +# (the plugin sources need the `osmflat_capi` target and its cbindgen header). + +set(OSMFLAT_MAPNIK_GIT_TAG "v4.2.2" CACHE STRING "Mapnik tag to build statically") +option(OSMFLAT_FULLY_STATIC "Link render with -static (Linux/musl: no shared libs at all)" OFF) +set(OSMFLAT_HARFBUZZ_GIT_TAG "11.2.1" CACHE STRING "harfbuzz tag built from source on macOS") + +if(APPLE AND OSMFLAT_FULLY_STATIC) + message(FATAL_ERROR "OSMFLAT_FULLY_STATIC needs musl; macOS always links libSystem dynamically") +endif() + +# --- Mapnik build options ---------------------------------------------------- +# Only what `render` uses: the AGG renderer writing PNG, fonts via FreeType + +# HarfBuzz + ICU. No PROJ (render rewrites the WGS84 / web-mercator proj4 +# strings to their EPSG codes, which Mapnik handles natively), no Cairo, no +# stock input plugins. +set(OSMFLAT_MAPNIK_OPTIONS + BUILD_SHARED_LIBS=OFF + BUILD_SHARED_PLUGINS=OFF + BUILD_TESTING=OFF + BUILD_BENCHMARK=OFF + BUILD_DEMO_VIEWER=OFF + BUILD_DEMO_CPP=OFF + BUILD_UTILITY_GEOMETRY_TO_WKB=OFF + BUILD_UTILITY_MAPNIK_INDEX=OFF + BUILD_UTILITY_MAPNIK_RENDER=OFF + BUILD_UTILITY_OGRINDEX=OFF + BUILD_UTILITY_PGSQL2SQLITE=OFF + BUILD_UTILITY_SHAPEINDEX=OFF + BUILD_UTILITY_SVG2PNG=OFF + INSTALL_DEPENDENCIES=OFF + USE_PNG=ON + USE_JPEG=OFF + USE_TIFF=OFF + USE_WEBP=OFF + USE_AVIF=OFF + USE_LIBXML2=OFF + USE_CAIRO=OFF + USE_PROJ=OFF + USE_GRID_RENDERER=OFF + USE_SVG_RENDERER=OFF + # The plugin's MAPNIK_LOG_DEBUG lines (OSMFLAT_LOG_DEBUG=1) need this. + USE_LOG=ON + USE_PLUGIN_INPUT_CSV=OFF + USE_PLUGIN_INPUT_GDAL=OFF + USE_PLUGIN_INPUT_OGR=OFF + USE_PLUGIN_INPUT_GDAL_OGR=OFF + USE_PLUGIN_INPUT_GEOBUF=OFF + USE_PLUGIN_INPUT_GEOJSON=OFF + USE_PLUGIN_INPUT_POSTGIS=OFF + USE_PLUGIN_INPUT_PGRASTER=OFF + USE_PLUGIN_INPUT_POSTGIS_PGRASTER=OFF + USE_PLUGIN_INPUT_RASTER=OFF + USE_PLUGIN_INPUT_SHAPE=OFF + USE_PLUGIN_INPUT_SQLITE=OFF + USE_PLUGIN_INPUT_TILES=OFF + USE_PLUGIN_INPUT_TOPOJSON=OFF +) +foreach(opt IN LISTS OSMFLAT_MAPNIK_OPTIONS) + string(REPLACE "=" ";" kv "${opt}") + list(GET kv 0 key) + list(GET kv 1 value) + set(${key} ${value} CACHE BOOL "" FORCE) +endforeach() + +# Static dependency archives (libicuuc.a, libfreetype.a, ...) rather than .so. +set(Boost_USE_STATIC_LIBS ON) +set(ICU_USE_STATIC_LIBS ON) + +if(APPLE) + # Everything third-party goes in; only macOS' own dylibs stay dynamic. + set(CMAKE_FIND_LIBRARY_SUFFIXES .a) + # Homebrew's Boost::regex names icu libs as bare `icuuc`/`icudata`/... which + # the linker can't resolve (icu4c is keg-only) and which would anyway pick + # the dylib over the archive. Mapnik's own workaround drops them; it links + # ICU::uc/data/i18n itself, by full path to the .a. + set(USE_BOOST_REGEX_ICU_WORKAROUND ON CACHE BOOL "" FORCE) + # Homebrew keeps icu4c, zlib and bzip2 keg-only: their archives aren't + # symlinked into the prefix, so nothing finds libicuuc.a / libz.a / + # libbz2.a without being told where they live. + find_program(OSMFLAT_BREW brew) + if(OSMFLAT_BREW) + if(NOT ICU_ROOT AND NOT DEFINED ENV{ICU_ROOT}) + # Homebrew versions icu4c; take whichever of these is installed. + foreach(icu_formula icu4c@76 icu4c@77 icu4c) + if(NOT ICU_ROOT) + execute_process(COMMAND ${OSMFLAT_BREW} --prefix ${icu_formula} + OUTPUT_VARIABLE ICU_ROOT OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + endif() + endforeach() + endif() + foreach(keg zlib bzip2) + execute_process(COMMAND ${OSMFLAT_BREW} --prefix ${keg} + OUTPUT_VARIABLE keg_prefix OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + if(keg_prefix) + list(APPEND CMAKE_PREFIX_PATH "${keg_prefix}") + endif() + endforeach() + endif() + + # Homebrew's libharfbuzz.a is built against glib and graphite2, and + # graphite2 has no static archive -- so a binary using it would still need + # Homebrew at runtime. Build harfbuzz ourselves instead, with just freetype + # and CoreText, which drops glib/graphite2/pcre2/intl entirely. + set(HB_HAVE_FREETYPE ON CACHE BOOL "" FORCE) + set(HB_HAVE_CORETEXT ON CACHE BOOL "" FORCE) + set(HB_HAVE_GLIB OFF CACHE BOOL "" FORCE) + set(HB_HAVE_GRAPHITE2 OFF CACHE BOOL "" FORCE) + set(HB_HAVE_ICU OFF CACHE BOOL "" FORCE) + set(HB_BUILD_SUBSET OFF CACHE BOOL "" FORCE) + set(HB_BUILD_UTILS OFF CACHE BOOL "" FORCE) + set(HB_BUILD_TESTS OFF CACHE BOOL "" FORCE) + FetchContent_Declare( + harfbuzz + GIT_REPOSITORY https://github.com/harfbuzz/harfbuzz.git + GIT_TAG ${OSMFLAT_HARFBUZZ_GIT_TAG} + GIT_SHALLOW TRUE + EXCLUDE_FROM_ALL + ) + FetchContent_MakeAvailable(harfbuzz) + + # Mapnik looks for harfbuzz with find_package(CONFIG) and expects a + # harfbuzz::harfbuzz target. Point that lookup at a generated config that + # aliases the target we just built, so it never sees Homebrew's (which + # would also collide on the target name). + set(shim "${CMAKE_BINARY_DIR}/harfbuzz-config-shim") + file(WRITE "${shim}/harfbuzzConfig.cmake" +"set(harfbuzz_FOUND TRUE) +set(harfbuzz_VERSION \"${OSMFLAT_HARFBUZZ_GIT_TAG}\") +if(NOT TARGET harfbuzz::harfbuzz) + add_library(harfbuzz::harfbuzz ALIAS harfbuzz) +endif() +") + set(harfbuzz_DIR "${shim}" CACHE PATH "" FORCE) +endif() +if(OSMFLAT_FULLY_STATIC) + set(CMAKE_FIND_LIBRARY_SUFFIXES .a) + # --static so pkg-config also reports Libs.private (harfbuzz needs glib, + # graphite2, freetype's bz2/brotli, ...), which only matters when linking + # against the archives. + set(PKG_CONFIG_ARGN --static) + # harfbuzz's own CMake config names the shared library, which a -static + # link refuses ("attempted static link of dynamic object"). Hiding the + # config package makes Mapnik take its pkg-config fallback instead. + set(CMAKE_DISABLE_FIND_PACKAGE_harfbuzz ON) + # pkg_check_modules caches harfbuzz_FOUND; left over from an earlier + # configure it would send Mapnik down the (now disabled) config branch and + # fail on a missing harfbuzz::harfbuzz target. + unset(harfbuzz_FOUND CACHE) +endif() + +# Read by the generated plugins/input/osmflat/CMakeLists.txt. +set(OSMFLAT_SOURCE_DIR ${CMAKE_SOURCE_DIR}) + +FetchContent_Declare( + mapnik + GIT_REPOSITORY https://github.com/mapnik/mapnik.git + GIT_TAG ${OSMFLAT_MAPNIK_GIT_TAG} + GIT_SHALLOW TRUE + GIT_SUBMODULES deps/mapbox/geometry deps/mapbox/polylabel deps/mapbox/protozero deps/mapbox/variant + PATCH_COMMAND ${CMAKE_COMMAND} + -DMAPNIK_SOURCE_DIR= + -DOSMFLAT_SOURCE_DIR=${CMAKE_SOURCE_DIR} + -P ${CMAKE_SOURCE_DIR}/cmake/static-mapnik/patch-mapnik.cmake + UPDATE_DISCONNECTED TRUE + EXCLUDE_FROM_ALL +) +FetchContent_MakeAvailable(mapnik) + +# libmapnik now compiles our plugin sources, which include the cbindgen header +# that `cargo build` writes -- so cargo has to run first. +add_dependencies(mapnik cargo-build_osmflat_capi) + +# Cargo reports `gcc_s` among the native libs its staticlib needs, but musl +# toolchains ship only the shared libgcc_s -- the static unwinder there is +# libgcc_eh.a. Swap it in Corrosion's link interface, or `-static` fails with +# "cannot find -lgcc_s". +if(OSMFLAT_FULLY_STATIC) + foreach(tgt osmflat_capi osmflat_capi-static) + if(TARGET ${tgt}) + get_target_property(_libs ${tgt} INTERFACE_LINK_LIBRARIES) + if(_libs) + string(REPLACE "gcc_s" "gcc_eh" _libs "${_libs}") + set_target_properties(${tgt} PROPERTIES INTERFACE_LINK_LIBRARIES "${_libs}") + endif() + endif() + endforeach() +endif() + +# --- render ------------------------------------------------------------------ +add_executable(render test/render.cpp) +# mapnik::mapnik before osmflat_capi: libmapnik.a references the Rust symbols, +# and single-pass linkers resolve left to right. +target_link_libraries(render PRIVATE mapnik::mapnik osmflat_capi) +if(APPLE) + # Homebrew's libfreetype.a carries its bzip2/brotli font decompressors, but + # FindFreetype reports only the archive itself. + find_library(OSMFLAT_BZ2_LIBRARY NAMES bz2) + find_library(OSMFLAT_BROTLIDEC_LIBRARY NAMES brotlidec) + find_library(OSMFLAT_BROTLICOMMON_LIBRARY NAMES brotlicommon) + foreach(lib OSMFLAT_BZ2_LIBRARY OSMFLAT_BROTLIDEC_LIBRARY OSMFLAT_BROTLICOMMON_LIBRARY) + if(${lib}) + target_link_libraries(render PRIVATE ${${lib}}) + endif() + endforeach() +endif() +target_compile_definitions(render PRIVATE OSMFLAT_STATIC_RENDER) +if(OSMFLAT_FULLY_STATIC) + target_link_options(render PRIVATE -static) +endif() + +enable_testing() +set(FIXTURES ${CMAKE_SOURCE_DIR}/test/fixtures) +# Same smoke test as the dynamic build. The plugin_dir argument is ignored by +# a static render, so pass a path that doesn't exist to prove it. +add_test(NAME render_relations + COMMAND render ${CMAKE_BINARY_DIR}/no-plugins ${CMAKE_SOURCE_DIR}/test/style-relations.xml + ${FIXTURES}/baarle-hertog.osm.flat ${CMAKE_BINARY_DIR}/render_relations.png + 4.75 51.38 5.02 51.49 500 500) diff --git a/cmake/static-mapnik/patch-mapnik.cmake b/cmake/static-mapnik/patch-mapnik.cmake new file mode 100644 index 0000000..9f10be7 --- /dev/null +++ b/cmake/static-mapnik/patch-mapnik.cmake @@ -0,0 +1,100 @@ +# Script mode (cmake -P): wire the osmflat datasource into a Mapnik source +# tree as a *built-in* plugin, so a static libmapnik (BUILD_SHARED_PLUGINS=OFF) +# carries it the same way it carries csv/geojson/shape. +# +# cmake -DMAPNIK_SOURCE_DIR= -DOSMFLAT_SOURCE_DIR= -P patch-mapnik.cmake +# +# Mapnik's static-plugin list is compile-time (datasource_cache_static.cpp + +# src/CMakeLists.txt), so there is no way to register an out-of-tree plugin +# without editing those files. Every edit anchors on an exact upstream string +# and fails loudly if the anchor is missing (i.e. Mapnik changed under us), and +# is skipped if already applied, so re-running on a populated tree is safe. + +foreach(var MAPNIK_SOURCE_DIR OSMFLAT_SOURCE_DIR) + if(NOT ${var}) + message(FATAL_ERROR "patch-mapnik.cmake: ${var} is required") + endif() +endforeach() + +function(osmflat_patch_file file anchor replacement marker) + set(path "${MAPNIK_SOURCE_DIR}/${file}") + file(READ "${path}" content) + string(FIND "${content}" "${marker}" already) + if(NOT already EQUAL -1) + return() + endif() + string(FIND "${content}" "${anchor}" found) + if(found EQUAL -1) + message(FATAL_ERROR "patch-mapnik.cmake: anchor not found in ${file}:\n${anchor}") + endif() + string(REPLACE "${anchor}" "${replacement}" content "${content}") + file(WRITE "${path}" "${content}") +endfunction() + +# 1. The plugin directory: a CMakeLists that adds our sources to an +# `input-osmflat` target, plus the header datasource_cache_static.cpp +# includes to see `osmflat_datasource_plugin`. +set(plugin_dir "${MAPNIK_SOURCE_DIR}/plugins/input/osmflat") +file(MAKE_DIRECTORY "${plugin_dir}") +configure_file("${CMAKE_CURRENT_LIST_DIR}/plugin/CMakeLists.txt" "${plugin_dir}/CMakeLists.txt" COPYONLY) +configure_file("${CMAKE_CURRENT_LIST_DIR}/plugin/osmflat_datasource.hpp.in" "${plugin_dir}/osmflat_datasource.hpp" @ONLY) + +# 2. Build it alongside the stock plugins. +osmflat_patch_file(plugins/input/CMakeLists.txt + "if(USE_PLUGIN_INPUT_TILES)" + "if(TARGET osmflat_capi)\n add_subdirectory(osmflat)\n list(APPEND m_build_plugins input-osmflat)\nendif()\nif(USE_PLUGIN_INPUT_TILES)" + "add_subdirectory(osmflat)") + +# 3. Link its sources into libmapnik and define MAPNIK_STATIC_PLUGIN_OSMFLAT. +osmflat_patch_file(src/CMakeLists.txt + " $<$>,$>:input-tiles>\n" + " $<$>,$>:input-tiles>\n $<$>,$>:input-osmflat>\n" + "TARGET_EXISTS:input-osmflat>>:input-osmflat>") +osmflat_patch_file(src/CMakeLists.txt + " $<$>,$>:MAPNIK_STATIC_PLUGIN_TILES>\n" + " $<$>,$>:MAPNIK_STATIC_PLUGIN_TILES>\n $<$>,$>:MAPNIK_STATIC_PLUGIN_OSMFLAT>\n" + "MAPNIK_STATIC_PLUGIN_OSMFLAT") + +# 4. Accept "+proj=longlat ..." without PROJ, classified the way PROJ +# classifies it. +# +# Built with PROJ, `proj_get_type` reports a proj4 longlat string as +# PJ_TYPE_OTHER_CRS, so `is_geographic_` stays false and scale denominators +# come out in degrees -- which is what every style written against this +# plugin is tuned for (a MaxScaleDenominator of 0.1 ~ neighborhood). Without +# PROJ, Mapnik knows only "epsg:4326" (geographic, so ~111319x larger +# denominators) and throws on anything else. Matching PROJ's classification +# here keeps styles renderer-independent; other proj4 strings still throw. +osmflat_patch_file(src/projection.cpp +[==[#ifdef MAPNIK_USE_PROJ + init_proj(); +#else + throw std::runtime_error(std::string("Cannot initialize projection '") + params_ + + " ' without proj support (-DMAPNIK_USE_PROJ)"); +#endif]==] +[==[#ifdef MAPNIK_USE_PROJ + init_proj(); +#else + // osmflat: PROJ reports a proj4 longlat string as a non-geographic + // "other" CRS; mirror that so scale denominators stay in degrees. + if (params_.rfind("+proj=longlat", 0) == 0) + { + is_geographic_ = false; + } + else + { + throw std::runtime_error(std::string("Cannot initialize projection '") + params_ + + " ' without proj support (-DMAPNIK_USE_PROJ)"); + } +#endif]==] +[==[osmflat: PROJ reports a proj4 longlat string]==]) + +# 5. Register it in the static datasource table. +osmflat_patch_file(src/datasource_cache_static.cpp + "#if defined(MAPNIK_STATIC_PLUGIN_TOPOJSON)\n#include \"input/topojson/topojson_datasource.hpp\"\n#endif\n" + "#if defined(MAPNIK_STATIC_PLUGIN_TOPOJSON)\n#include \"input/topojson/topojson_datasource.hpp\"\n#endif\n#if defined(MAPNIK_STATIC_PLUGIN_OSMFLAT)\n#include \"input/osmflat/osmflat_datasource.hpp\"\n#endif\n" + "input/osmflat/osmflat_datasource.hpp") +osmflat_patch_file(src/datasource_cache_static.cpp + " REGISTER_STATIC_DATASOURCE_PLUGIN(topojson_datasource_plugin);\n#endif\n" + " REGISTER_STATIC_DATASOURCE_PLUGIN(topojson_datasource_plugin);\n#endif\n#if defined(MAPNIK_STATIC_PLUGIN_OSMFLAT)\n REGISTER_STATIC_DATASOURCE_PLUGIN(osmflat_datasource_plugin);\n#endif\n" + "REGISTER_STATIC_DATASOURCE_PLUGIN(osmflat_datasource_plugin)") diff --git a/cmake/static-mapnik/plugin/CMakeLists.txt b/cmake/static-mapnik/plugin/CMakeLists.txt new file mode 100644 index 0000000..38fa59e --- /dev/null +++ b/cmake/static-mapnik/plugin/CMakeLists.txt @@ -0,0 +1,21 @@ +# Copied into /plugins/input/osmflat by patch-mapnik.cmake. +# +# With BUILD_SHARED_PLUGINS=OFF, add_plugin_target makes an INTERFACE library +# whose sources compile into libmapnik itself. OSMFLAT_SOURCE_DIR comes from the +# parent project (cmake/StaticRender.cmake). Paths are wrapped in +# $ so Mapnik's install(EXPORT) never sees source-tree paths. +add_plugin_target(input-osmflat "osmflat") + +target_sources(input-osmflat ${_plugin_visibility} + $ + $ + $ + $ +) +target_include_directories(input-osmflat ${_plugin_visibility} + $ + $ +) +target_link_libraries(input-osmflat ${_plugin_visibility} + mapnik::mapnik +) diff --git a/cmake/static-mapnik/plugin/osmflat_datasource.hpp.in b/cmake/static-mapnik/plugin/osmflat_datasource.hpp.in new file mode 100644 index 0000000..4d34689 --- /dev/null +++ b/cmake/static-mapnik/plugin/osmflat_datasource.hpp.in @@ -0,0 +1,13 @@ +// Generated into /plugins/input/osmflat by patch-mapnik.cmake. +// +// datasource_cache_static.cpp includes "input//_datasource.hpp" +// and expects it to declare `_datasource_plugin`. Our plugin class is +// declared in src/osmflat_datasource.cpp (for the dynamic build), so re-declare +// it here with the same macro; the definitions stay in that .cpp. +#pragma once + +#include "@OSMFLAT_SOURCE_DIR@/include/osmflat_datasource.hpp" + +#include + +DATASOURCE_PLUGIN_DEF(osmflat_datasource_plugin, osmflat); diff --git a/scripts/build-static-render-macos.sh b/scripts/build-static-render-macos.sh new file mode 100755 index 0000000..a4e0ec3 --- /dev/null +++ b/scripts/build-static-render-macos.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Build a self-contained `render` on macOS: mapnik and every third-party +# library linked in, leaving only macOS' own dylibs/frameworks dynamic (libSystem +# can't be linked statically). Run from the repo root on an arm64 or x86_64 Mac: +# +# sh scripts/build-static-render-macos.sh +# +# Output: dist/osmflat-render---apple-darwin.tar.gz +set -euo pipefail + +BUILD_DIR="${BUILD_DIR:-build-static}" +DIST_DIR="${DIST_DIR:-dist}" +# Binaries built on a newer runner won't start on older macOS unless a +# deployment target is declared. Homebrew's bottles target the runner's OS, so +# this can't go below it. +MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-14.0}" +export MACOSX_DEPLOYMENT_TARGET + +# No auto-update / no upgrading of already-installed formulae: this script +# runs on developer machines too, and shouldn't reshuffle someone's Homebrew. +export HOMEBREW_NO_AUTO_UPDATE=1 +export HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 +brew install --quiet boost icu4c@76 freetype libpng bzip2 zlib ninja cmake >/dev/null + +# DejaVu is what the styles ask for by face-name, and macOS doesn't ship it. +# Pinned rather than taken from a cask, so both platforms package the same +# font release. +fonts_dir="$BUILD_DIR/dejavu" +if [ ! -d "$fonts_dir" ]; then + mkdir -p "$fonts_dir" + curl -fsSL https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.tar.bz2 \ + | tar xj -C "$fonts_dir" --strip-components=2 'dejavu-fonts-ttf-2.37/ttf/*.ttf' +fi + +cmake -S . -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DOSMFLAT_STATIC_RENDER=ON \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$MACOSX_DEPLOYMENT_TARGET" +cmake --build "$BUILD_DIR" --target render --parallel "$(sysctl -n hw.ncpu)" +strip -S -x "$BUILD_DIR/render" + +# Nothing outside /usr/lib and /System may be linked, or the binary only runs +# on a machine with Homebrew in the same place. +otool -L "$BUILD_DIR/render" +if otool -L "$BUILD_DIR/render" | tail -n +2 | grep -vE '^\s+(/usr/lib/|/System/)'; then + echo "error: render links a non-system library (see above)" >&2 + exit 1 +fi + +MAPNIK_FONT_DIR="$fonts_dir" ctest --test-dir "$BUILD_DIR" --output-on-failure + +# uname says arm64; name the archive the way the toolchain triple does. +arch="$(uname -m)" +[ "$arch" = arm64 ] && arch=aarch64 +sh scripts/package-render.sh "$BUILD_DIR" "$DIST_DIR" "${arch}-apple-darwin" "$fonts_dir" diff --git a/scripts/build-static-render.sh b/scripts/build-static-render.sh new file mode 100755 index 0000000..489cddb --- /dev/null +++ b/scripts/build-static-render.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Build a fully static `render` (musl, Mapnik + osmflat compiled in) inside an +# Alpine container, run the smoke test, and package it with DejaVu fonts. +# +# Run from the repo root, in CI or locally: +# +# docker run --rm -v "$PWD:/src" -w /src alpine:3.22 sh scripts/build-static-render.sh +# podman run --rm -v "$PWD:/src" -w /src alpine:3.22 sh scripts/build-static-render.sh +# +# Output: dist/osmflat-render---linux-musl.tar.gz +# (packaging itself lives in scripts/package-render.sh, shared with macOS) +# +# Everything reusable lives under $BUILD_DIR (mapnik's fetched source + objects, +# the cargo registry, downloaded apk packages) so CI can cache that one path and +# a second run only relinks what changed. +set -eu + +BUILD_DIR="${BUILD_DIR:-build-static}" +DIST_DIR="${DIST_DIR:-dist}" +# Absolute: cmake/cargo are invoked from various working directories. +CARGO_HOME="${CARGO_HOME:-$PWD/$BUILD_DIR/cargo-home}" +APK_CACHE="${APK_CACHE:-$PWD/$BUILD_DIR/apk-cache}" +export CARGO_HOME +mkdir -p "$APK_CACHE" + +apk add --cache-dir "$APK_CACHE" \ + build-base cmake samurai git pkgconf curl ca-certificates linux-headers file \ + rust cargo \ + boost1.84-dev boost1.84-static \ + icu-dev icu-static \ + freetype-dev freetype-static \ + harfbuzz-dev harfbuzz-static graphite2-static glib-static pcre2-static \ + libpng-dev libpng-static zlib-dev zlib-static bzip2-static brotli-static expat-static \ + font-dejavu + +cmake -S . -B "$BUILD_DIR" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DOSMFLAT_STATIC_RENDER=ON \ + -DOSMFLAT_FULLY_STATIC=ON +cmake --build "$BUILD_DIR" --target render --parallel "$(nproc)" +strip "$BUILD_DIR/render" + +file "$BUILD_DIR/render" +if file "$BUILD_DIR/render" | grep -q 'dynamically linked'; then + echo "error: render is dynamically linked" >&2 + exit 1 +fi + +MAPNIK_FONT_DIR=/usr/share/fonts/dejavu ctest --test-dir "$BUILD_DIR" --output-on-failure + +sh scripts/package-render.sh "$BUILD_DIR" "$DIST_DIR" "$(uname -m)-linux-musl" /usr/share/fonts/dejavu diff --git a/scripts/package-render.sh b/scripts/package-render.sh new file mode 100755 index 0000000..b170e72 --- /dev/null +++ b/scripts/package-render.sh @@ -0,0 +1,55 @@ +#!/bin/sh +# Package a built `render` into dist/osmflat-render--.tar.gz +# with the fonts it falls back to and the licenses it has to carry. +# +# package-render.sh +# +# A tag build passes OSMFLAT_RENDER_VERSION so the archive name matches the +# release; otherwise the project version is used. +set -eu + +BUILD_DIR="$1" +DIST_DIR="$2" +PLATFORM="$3" +FONTS_DIR="$4" + +version="${OSMFLAT_RENDER_VERSION:-$(sed -n 's/^project(osmflat-mapnik-plugin VERSION \([^ )]*\).*/\1/p' CMakeLists.txt)}" +name="osmflat-render-${version}-${PLATFORM}" +rm -rf "$DIST_DIR/$name" +mkdir -p "$DIST_DIR/$name/fonts" +cp "$BUILD_DIR/render" "$DIST_DIR/$name/" +cp "$FONTS_DIR"/*.ttf "$DIST_DIR/$name/fonts/" +cp LICENSE-MIT README.md "$DIST_DIR/$name/" + +# mapnik is LGPL-2.1 and linked into the binary, so ship its license text and +# say where the sources and relink instructions are. +cp "$BUILD_DIR/_deps/mapnik-src/COPYING" "$DIST_DIR/$name/LICENSE.mapnik" +mapnik_tag="$(git -C "$BUILD_DIR/_deps/mapnik-src" describe --tags --always 2>/dev/null || echo unknown)" +cat > "$DIST_DIR/$name/NOTICE" < #include +#include +#ifdef __APPLE__ +#include +#include +#endif #include #include #include @@ -32,6 +37,32 @@ static std::string slurp(const std::string& path) return ss.str(); } +#ifdef OSMFLAT_STATIC_RENDER +// Directory holding the running executable, following symlinks (e.g. a +// /usr/local/bin/render link into an unpacked release tarball). +static std::string executable_dir(const char* argv0) +{ + std::error_code ec; + std::filesystem::path exe; +#ifdef __APPLE__ + char buf[PATH_MAX]; + uint32_t size = sizeof(buf); + if (_NSGetExecutablePath(buf, &size) == 0) { + exe = std::filesystem::canonical(buf, ec); + } else { + ec = std::make_error_code(std::errc::filename_too_long); + } +#else + exe = std::filesystem::read_symlink("/proc/self/exe", ec); +#endif + if (ec) { + ec.clear(); + exe = std::filesystem::canonical(argv0, ec); + } + return ec ? std::string(".") : exe.parent_path().string(); +} +#endif + int main(int argc, char** argv) { if (argc != 9 && argc != 11) { @@ -59,12 +90,24 @@ int main(int argc, char** argv) } mapnik::setup(); +#ifdef OSMFLAT_STATIC_RENDER + // osmflat is compiled into this binary's static libmapnik. Loading + // .input modules from disk would pull in a second, shared libmapnik, + // so plugin_dir is accepted (for a stable CLI) but ignored. + (void)plugin_dir; +#else mapnik::datasource_cache::instance().register_datasources(plugin_dir); +#endif // Register bundled DejaVu fonts so TextSymbolizer can resolve face-name. if (const char* fd = std::getenv("MAPNIK_FONT_DIR")) { mapnik::freetype_engine::register_fonts(fd, true); } else { +#ifdef OSMFLAT_STATIC_RENDER + // The release tarball ships DejaVu in fonts/ beside the binary. + mapnik::freetype_engine::register_fonts(executable_dir(argv[0]) + "/fonts", true); +#else mapnik::freetype_engine::register_fonts("/opt/homebrew/lib/mapnik/fonts", true); +#endif } std::string xml = slurp(style_xml);