Compare commits

..

7 Commits

15 changed files with 296 additions and 27 deletions
+1
View File
@@ -1,4 +1,5 @@
.git/
.env
source/
tiles/
cache/
+10
View File
@@ -0,0 +1,10 @@
# Optional HTTPS XYZ template used for the base layer and tiles without Arma coverage.
# Leave blank to return tiles/empty.png for uncovered coordinates.
CALLBACK_PROVIDER=
# Local HTTP port and build parallelism.
HTTP_PORT=9000
GDAL2TILES_PROCESSES=1
# Optional absolute path used by Coolify for the persistent source directory.
# ARMA_TILES_SOURCE_DIR=/data/arma-tiles/source
+4 -2
View File
@@ -1,6 +1,7 @@
# Generated raster layer and transactional build state
tiles/
cache/
.env
.tiles-staging-*/
.tiles-work-*/
.tiles-previous/
@@ -13,5 +14,6 @@ cache/
__pycache__/
*.py[cod]
# KMZ Files
source/
# Input folder structure is versioned, but map inputs stay out of Git.
source/**/*.kml
source/**/*.kmz
+9
View File
@@ -0,0 +1,9 @@
# Arma tiles agent guide
- Keep `source/` in version control through `.gitkeep`, but never commit `.kml` or `.kmz` inputs. They are deployment data mounted through `ARMA_TILES_SOURCE_DIR`.
- The generated result belongs in `tiles/`; do not hand-edit it. Run `docker compose up --build` or `./scripts/build-tiles.sh` to regenerate it.
- Preserve the public Arma endpoint format: `/tiles/{x}/{y}/{z}`. Internal files remain `tiles/{z}/{x}/{y}.png`.
- `CALLBACK_PROVIDER` is optional. When set, it must be an HTTPS template containing exactly `{z}`, `{x}`, and `{y}` in its path. Validate it before a tile build; no provider-specific host or branding belongs in application code or documentation.
- With a callback provider, configure MapLibre with `/base/{x}/{y}/{z}` as its raster layer below `/tiles/{x}/{y}/{z}`. This makes the provider visible through transparent edges of Arma overlays.
- Keep source processing idempotent, do not modify input files, and preserve per-source error reporting in `tiles/metadata.json`.
- Before handing off changes, run `python3 tests/test_build_tiles.py`, `docker compose config --quiet`, and `docker compose build tiles` when Docker is available.
+5
View File
@@ -20,4 +20,9 @@ RUN chmod +x /app/scripts/build-tiles.sh /app/scripts/build-tiles.py
ENTRYPOINT ["/app/scripts/build-tiles.sh"]
FROM nginx:1.27-alpine AS tile-server
RUN apk add --no-cache python3
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY nginx/00-callback-provider-cache.conf /etc/nginx/conf.d/00-callback-provider-cache.conf
COPY scripts/callback_provider.py /usr/local/bin/callback-provider.py
COPY nginx/render-callback-provider.sh /docker-entrypoint.d/40-render-callback-provider.sh
RUN chmod +x /docker-entrypoint.d/40-render-callback-provider.sh
+44 -7
View File
@@ -8,7 +8,7 @@ Servidor estático de tiles raster XYZ para os mapas de Arma 3 fornecidos como K
Cada overlay gera tiles temporários por `gdal2tiles --xyz`. O builder os compõe com alfa em uma única pirâmide. Uma área menor tem prioridade e é desenhada por cima; se as áreas forem iguais, vence primeiro o caminho relativo em ordem alfabética e depois o índice do `GroundOverlay`. A área é calculada a partir do polígono rotacionado em EPSG:3857.
O resultado físico é:
O resultado físico é publicado diretamente na pasta local `tiles/`:
```text
tiles/{z}/{x}/{y}.png
@@ -22,7 +22,44 @@ https://arma_tiles.valmo.dev/tiles/{x}/{y}/{z}
https://arma_tiles.valmo.dev/tiles/18342/12417/15
```
Mesmo sem `.png` na URL, a resposta é `Content-Type: image/png`. Tiles ausentes, inclusive áreas sem cobertura, respondem `404`.
Mesmo sem `.png` na URL, a resposta é `Content-Type: image/png` e inclui `Access-Control-Allow-Origin: *`, permitindo uso direto pelo MapLibre em outra origem. Sem provider configurado, coordenadas sem cobertura retornam `tiles/empty.png`; com provider configurado, são encaminhadas ao callback e armazenadas no cache local do Nginx.
## Provider de callback e MapLibre
Copie `.env.example` para `.env` e, se quiser uma camada base, defina um template HTTPS XYZ:
```dotenv
CALLBACK_PROVIDER=https://tiles.seu-provider.example/{z}/{x}/{y}.png
```
O template é validado antes de qualquer leitura/processamento de fonte. Ele precisa usar `https`, não pode ter query, credenciais ou IP privado, e deve conter exatamente um `{z}`, `{x}` e `{y}` no caminho. Deixe a variável vazia ou ausente para usar o `empty.png` branco.
Para que o provider apareça tanto onde não há mapa Arma quanto nas bordas transparentes de uma tile parcialmente coberta, use a camada base local abaixo da camada Arma:
```js
sources: {
providerBase: {
type: "raster",
tiles: ["http://localhost:9000/base/{x}/{y}/{z}"],
tileSize: 256,
scheme: "xyz"
},
arma: {
type: "raster",
tiles: ["http://localhost:9000/tiles/{x}/{y}/{z}"],
tileSize: 256,
scheme: "xyz",
minzoom: 0,
maxzoom: 17
}
},
layers: [
{ id: "provider-base", type: "raster", source: "providerBase" },
{ id: "arma", type: "raster", source: "arma" }
]
```
`/base/{x}/{y}/{z}` e o fallback de `/tiles/` consultam o callback sob demanda e compartilham um cache local persistente. Exiba a atribuição e cumpra os termos definidos pelo provider escolhido.
## Entradas
@@ -34,7 +71,7 @@ Os arquivos de entrada nunca são modificados ou removidos. Erros por arquivo s
O máximo automático de cada overlay é calculado a partir da maior resolução reprojetada em metros por pixel. O builder escolhe o maior zoom cuja resolução de tile ainda não é mais detalhada que a fonte, evitando ampliação de pixels.
O mínimo automático é `clamp(maxzoom - 7, 6, 10)`, oferecendo alguns níveis de contexto sem criar uma pirâmide mundial vazia. Cada fonte para no próprio máximo: acima dele não haverá tile direto dessa fonte. Se nenhuma outra fonte cobrir a coordenada, a resposta será `404`.
O mínimo automático é `0`: os níveis amplos custam pouco e tornam mapas isolados encontráveis a partir da visão mundial. Cada fonte para no próprio máximo nativo, evitando ampliar pixels no servidor. Para permitir zoom visual adicional, configure o MapLibre com `maxZoom` alto e mantenha o `maxzoom` da source no máximo nativo; o MapLibre amplia a última tile disponível. Se nenhuma fonte cobrir a coordenada, o servidor usa o callback configurado ou o `empty.png` branco.
`config/maps.json` permite overrides globais e por caminho relativo a `source/`:
@@ -59,7 +96,7 @@ Dependências locais: Python 3, Pillow, GDAL com `gdal_translate`, `gdalwarp`, `
./scripts/build-tiles.sh --source-filter Altis.kmz
```
O cache em `cache/` usa SHA-256 da fonte. Entradas inalteradas reutilizam os GeoTIFFs reprojetados. Para garantir a composição e a prioridade corretas, a v1 recompõe a pirâmide final inteira em staging a cada build; apenas a reprojeção das fontes inalteradas é reutilizada. O diretório final é publicado somente após a geração de `metadata.json`.
O cache em `cache/` usa SHA-256 da fonte. Entradas inalteradas reutilizam os GeoTIFFs reprojetados. Para garantir a composição e a prioridade corretas, a v1 recompõe a pirâmide final inteira em staging a cada build; apenas a reprojeção das fontes inalteradas é reutilizada. Ao fim do build, a pasta local `tiles/` é substituída pelo resultado completo, após a geração de `metadata.json`.
## Docker Compose e Coolify
@@ -69,9 +106,9 @@ Para desenvolvimento local:
docker compose up --build
```
O Nginx ficará disponível em `http://localhost:8080`. O Compose executa primeiro o builder e monta o volume de tiles como somente leitura no Nginx.
O Nginx ficará disponível em `http://localhost:${HTTP_PORT:-9000}`. O Compose executa primeiro o builder e monta a pasta `tiles/` como somente leitura no Nginx.
No Coolify, mantenha as entradas fora do Git e crie uma pasta/volume persistente no host. Defina `ARMA_TILES_SOURCE_DIR` para esse caminho; ele será montado como `/app/source` somente leitura. O volume nomeado `arma_tiles_data` preserva tiles e cache entre deploys. Configure o domínio `arma_tiles.valmo.dev` e TLS no proxy do Coolify, apontando para a porta 80 do serviço `tiles`.
No Coolify, mantenha as entradas fora do Git e crie uma pasta/volume persistente no host. Defina `ARMA_TILES_SOURCE_DIR` para esse caminho; ele será montado como `/app/source` somente leitura. O volume nomeado `arma_tiles_data` preserva o cache entre deploys; a pasta `tiles/` do projeto é o volume de saída publicado tanto pelo builder quanto pelo Nginx. Configure o domínio `arma_tiles.valmo.dev` e TLS no proxy do Coolify, apontando para a porta 80 do serviço `tiles`.
Para limitar paralelismo do GDAL, configure `GDAL2TILES_PROCESSES` (o padrão é `1`). As respostas de tile têm cache público de um dia e `stale-while-revalidate` de sete dias, além de ETag.
@@ -82,7 +119,7 @@ python3 tests/test_build_tiles.py
./scripts/smoke-test.sh
```
O teste de fumaça processa `Altis.kmz`, verifica `tiles/metadata.json` e um PNG, sobe o Nginx, consulta uma tile pela ordem pública `{x}/{y}/{z}` e confirma `200` com `image/png`; também confirma `404` para `/tiles/0/0/0`.
O teste de fumaça processa `Altis.kmz`, verifica `tiles/metadata.json` e um PNG, sobe o Nginx e consulta uma tile pela ordem pública `{x}/{y}/{z}` para confirmar `200`, `image/png` e CORS. O callback é opcional e não é exercitado pelo teste local.
## Limitações conhecidas
+9 -2
View File
@@ -6,11 +6,14 @@ services:
environment:
SOURCE_DIR: /app/source
DATA_DIR: /var/lib/arma-tiles
TILES_DIR: /output/tiles
MAPS_CONFIG: /app/config/maps.json
GDAL2TILES_PROCESSES: ${GDAL2TILES_PROCESSES:-1}
CALLBACK_PROVIDER: ${CALLBACK_PROVIDER:-}
volumes:
- ${ARMA_TILES_SOURCE_DIR:-./source}:/app/source:ro
- arma_tiles_data:/var/lib/arma-tiles
- ./tiles:/output/tiles
restart: "no"
tiles:
@@ -21,9 +24,13 @@ services:
tile-builder:
condition: service_completed_successfully
ports:
- "${HTTP_PORT:-8080}:80"
- "${HTTP_PORT:-9000}:80"
environment:
CALLBACK_PROVIDER: ${CALLBACK_PROVIDER:-}
volumes:
- arma_tiles_data:/var/lib/arma-tiles:ro
- ./tiles:/var/lib/arma-tiles/tiles:ro
- callback_provider_cache:/var/cache/nginx
volumes:
arma_tiles_data:
callback_provider_cache:
+2
View File
@@ -0,0 +1,2 @@
# Included inside nginx's http block by the stock nginx.conf.
proxy_cache_path /var/cache/nginx/callback-provider levels=1:2 keys_zone=callback_provider:10m inactive=7d max_size=2g use_temp_path=off;
+19 -2
View File
@@ -8,9 +8,26 @@ server {
# Public API is x/y/z. try_files performs an internal lookup at z/x/y.png.
location ~ ^/tiles/(?<tile_x>[0-9]+)/(?<tile_y>[0-9]+)/(?<tile_z>[0-9]+)$ {
try_files /tiles/$tile_z/$tile_x/$tile_y.png =404;
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin, Range, Accept" always;
add_header Content-Length 0;
return 204;
}
try_files /tiles/$tile_z/$tile_x/$tile_y.png @callback_provider_tile;
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Origin, Range, Accept" always;
}
# A callback-provider base layer fills transparent edges when configured.
location ~ ^/base/(?<base_x>[0-9]+)/(?<base_y>[0-9]+)/(?<base_z>[0-9]+)$ {
try_files /__callback_provider_base__ @callback_provider_base;
}
include /etc/nginx/includes/callback-provider.conf;
# metadata.json and internal z/x/y.png paths are intentionally not public.
location / {
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
python3 /usr/local/bin/callback-provider.py --nginx-output /etc/nginx/includes/callback-provider.conf
+37 -13
View File
@@ -21,6 +21,11 @@ from xml.etree import ElementTree as ET
from PIL import Image
SCRIPT_DIRECTORY = Path(__file__).resolve().parent
if str(SCRIPT_DIRECTORY) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIRECTORY))
from callback_provider import ProviderValidationError, validate_callback_provider
PIPELINE_VERSION = "1.0.0"
WORLD_WIDTH_METERS = 40075016.68557849
@@ -147,7 +152,8 @@ def read_config(path: Path) -> dict:
def effective_zooms(auto_max: int, relative_source: str, config: dict) -> tuple[int, int, bool]:
auto_min = max(6, min(10, auto_max - 7))
# Low zooms are cheap (usually one tile per source) and make isolated maps discoverable from a world view.
auto_min = 0
override = config["sources"].get(relative_source, {})
minimum = override.get("minzoom")
maximum = override.get("maxzoom")
@@ -364,6 +370,10 @@ def compose_layer(source_tiles: Path, final_tiles: Path) -> None:
result.save(destination, format="PNG", optimize=False)
def write_empty_tile(tiles_dir: Path) -> None:
Image.new("RGBA", (256, 256), (255, 255, 255, 255)).save(tiles_dir / "empty.png", format="PNG", optimize=False)
def tile_overlay(overlay: dict, work_root: Path, final_tiles: Path, processes: int) -> None:
layer_root = work_root / f"layer-{overlay['priority']:04d}-{overlay['index']}"
help_result = subprocess.run(["gdal2tiles.py", "--help"], text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
@@ -376,16 +386,21 @@ def tile_overlay(overlay: dict, work_root: Path, final_tiles: Path, processes: i
shutil.rmtree(layer_root, ignore_errors=True)
def publish(stage: Path, data_dir: Path) -> None:
current = data_dir / "tiles"
backup = data_dir / ".tiles-previous"
if backup.exists():
shutil.rmtree(backup)
if current.exists():
current.rename(backup)
stage.rename(current)
if backup.exists():
shutil.rmtree(backup)
def remove_generated_path(path: Path) -> None:
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def publish(stage: Path, tiles_dir: Path) -> None:
"""Publish staged tiles into a directory which may be a bind mount."""
tiles_dir.mkdir(parents=True, exist_ok=True)
for existing in tiles_dir.iterdir():
remove_generated_path(existing)
for generated in stage.iterdir():
shutil.move(str(generated), str(tiles_dir / generated.name))
stage.rmdir()
def source_record(descriptor: dict, config: dict) -> dict:
@@ -415,8 +430,11 @@ def source_record(descriptor: dict, config: dict) -> dict:
def build(arguments: argparse.Namespace) -> None:
# Validate before reading or warping any source, so a malformed provider never wastes a full build.
validate_callback_provider(os.environ.get("CALLBACK_PROVIDER"))
source_dir = Path(arguments.source_dir).resolve()
data_dir = Path(arguments.data_dir).resolve()
tiles_dir = Path(arguments.tiles_dir).resolve()
config = read_config(Path(arguments.config).resolve())
if not source_dir.is_dir():
raise RuntimeError(f"diretório source inexistente: {source_dir}")
@@ -468,13 +486,15 @@ def build(arguments: argparse.Namespace) -> None:
"generated_at": datetime.now(UTC).isoformat(),
"minzoom": global_min,
"maxzoom": global_max,
"tile_background": "transparent",
"processed_sources": records,
"sources_with_error": errors,
"source_count": len(records),
"error_count": len(errors),
}
write_empty_tile(stage)
(stage / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
publish(stage, data_dir)
publish(stage, tiles_dir)
except Exception:
shutil.rmtree(stage, ignore_errors=True)
raise
@@ -486,10 +506,14 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-dir", default=os.environ.get("SOURCE_DIR", "source"))
parser.add_argument("--data-dir", default=os.environ.get("DATA_DIR", "."))
parser.add_argument("--tiles-dir", default=os.environ.get("TILES_DIR"))
parser.add_argument("--config", default=os.environ.get("MAPS_CONFIG", "config/maps.json"))
parser.add_argument("--source-filter", action="append", help="caminho relativo exato sob source/; repetível")
parser.add_argument("--processes", type=int, default=max(1, int(os.environ.get("GDAL2TILES_PROCESSES", "1"))))
return parser.parse_args()
arguments = parser.parse_args()
if arguments.tiles_dir is None:
arguments.tiles_dir = str(Path(arguments.data_dir) / "tiles")
return arguments
if __name__ == "__main__":
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Validate CALLBACK_PROVIDER and render its safe Nginx proxy locations."""
from __future__ import annotations
import argparse
import ipaddress
import re
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlsplit
class ProviderValidationError(ValueError):
pass
@dataclass(frozen=True)
class CallbackProvider:
origin: str
path_template: str
def validate_callback_provider(value: str | None) -> CallbackProvider | None:
if value is None or value == "":
return None
if value != value.strip() or len(value) > 2048 or any(character.isspace() for character in value):
raise ProviderValidationError("CALLBACK_PROVIDER não pode conter espaços e deve ter até 2048 caracteres")
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
raise ProviderValidationError("CALLBACK_PROVIDER deve ser uma URL HTTPS pública, sem usuário ou senha")
if parsed.query or parsed.fragment or not parsed.path.startswith("/"):
raise ProviderValidationError("CALLBACK_PROVIDER não aceita query, fragmento e exige um caminho absoluto")
try:
port = parsed.port
except ValueError as error:
raise ProviderValidationError("CALLBACK_PROVIDER possui porta inválida") from error
host = parsed.hostname
if not host or not re.fullmatch(r"[A-Za-z0-9.-]+", host) or ".." in host or host.startswith(".") or host.endswith("."):
raise ProviderValidationError("CALLBACK_PROVIDER possui host inválido")
try:
address = ipaddress.ip_address(host)
except ValueError:
address = None
if address is not None and not address.is_global:
raise ProviderValidationError("CALLBACK_PROVIDER não pode apontar para endereço IP privado ou reservado")
if port is not None and not 1 <= port <= 65535:
raise ProviderValidationError("CALLBACK_PROVIDER possui porta inválida")
if not re.fullmatch(r"/[A-Za-z0-9._~/%{}-]+", parsed.path) or "/../" in f"{parsed.path}/" or any(token in parsed.path for token in ("$", ";", "\\")):
raise ProviderValidationError("CALLBACK_PROVIDER possui caminho não permitido")
for token in ("{x}", "{y}", "{z}"):
if parsed.path.count(token) != 1:
raise ProviderValidationError("CALLBACK_PROVIDER deve conter exatamente uma ocorrência de {x}, {y} e {z}")
remaining_placeholders = parsed.path
for token in ("{x}", "{y}", "{z}"):
remaining_placeholders = remaining_placeholders.replace(token, "")
if "{" in remaining_placeholders or "}" in remaining_placeholders:
raise ProviderValidationError("CALLBACK_PROVIDER contém placeholder desconhecido")
return CallbackProvider(origin=f"https://{parsed.netloc}", path_template=parsed.path)
def render_location(name: str, provider: CallbackProvider | None, x: str, y: str, z: str, label: str) -> str:
headers = """ add_header Cache-Control \"public, max-age=86400, stale-while-revalidate=604800\" always;
add_header Access-Control-Allow-Origin \"*\" always;
add_header Access-Control-Allow-Methods \"GET, HEAD, OPTIONS\" always;
add_header Access-Control-Allow-Headers \"Origin, Range, Accept\" always;"""
if provider is None:
return f"""location @{name} {{
try_files /tiles/empty.png =500;
{headers}
}}
"""
rendered_path = provider.path_template.replace("{x}", f"${x}").replace("{y}", f"${y}").replace("{z}", f"${z}")
return f"""location @{name} {{
rewrite ^ {rendered_path} break;
proxy_pass {provider.origin};
proxy_set_header Host $proxy_host;
proxy_ssl_server_name on;
proxy_cache callback_provider;
proxy_cache_valid 200 7d;
proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504;
add_header X-Map-Source \"Callback provider {label}\" always;
{headers}
}}
"""
def render_nginx(provider: CallbackProvider | None) -> str:
return (
"# Generated at container startup. Do not edit.\n"
+ render_location("callback_provider_tile", provider, "tile_x", "tile_y", "tile_z", "fallback")
+ "\n"
+ render_location("callback_provider_base", provider, "base_x", "base_y", "base_z", "base")
)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--nginx-output", type=Path)
arguments = parser.parse_args()
provider = validate_callback_provider(__import__("os").environ.get("CALLBACK_PROVIDER"))
if arguments.nginx_output:
arguments.nginx_output.parent.mkdir(parents=True, exist_ok=True)
arguments.nginx_output.write_text(render_nginx(provider), encoding="utf-8")
if __name__ == "__main__":
try:
main()
except ProviderValidationError as error:
raise SystemExit(f"CALLBACK_PROVIDER inválido: {error}")
+2 -2
View File
@@ -35,6 +35,6 @@ done
headers="$(curl -sS -D - -o /dev/null "$base_url/tiles/$x/$y/$z")"
printf '%s\n' "$headers" | grep -qi '^HTTP/.* 200'
printf '%s\n' "$headers" | grep -qi '^Content-Type: image/png'
test "$(curl -sS -o /dev/null -w '%{http_code}' "$base_url/tiles/0/0/0")" = "404"
printf '%s\n' "$headers" | grep -qi '^Access-Control-Allow-Origin: \*'
printf 'Smoke test passou: /tiles/%s/%s/%s retornou PNG e tile descoberta retornou 404.\n' "$x" "$y" "$z"
printf 'Smoke test passou: /tiles/%s/%s/%s retornou PNG/CORS.\n' "$x" "$y" "$z"
+1
View File
@@ -0,0 +1 @@
+39
View File
@@ -26,12 +26,51 @@ class BuildTilesTests(unittest.TestCase):
config = {"global": {"minzoom": 7, "maxzoom": 12}, "sources": {"Altis.kmz": {"minzoom": 9, "maxzoom": 14}}}
self.assertEqual(build_tiles.effective_zooms(11, "Altis.kmz", config), (9, 14, True))
def test_callback_provider_accepts_safe_https_xyz_template(self):
provider = build_tiles.validate_callback_provider("https://tiles.example.org/maps/{z}/{x}/{y}.png")
self.assertEqual(provider.origin, "https://tiles.example.org")
def test_callback_provider_rejects_missing_or_unsafe_template(self):
with self.assertRaises(build_tiles.ProviderValidationError):
build_tiles.validate_callback_provider("http://tiles.example.org/{z}/{x}/{y}.png")
with self.assertRaises(build_tiles.ProviderValidationError):
build_tiles.validate_callback_provider("https://127.0.0.1/{z}/{x}/{y}.png")
with self.assertRaises(build_tiles.ProviderValidationError):
build_tiles.validate_callback_provider("https://tiles.example.org/{z}/{x}.png")
def test_config_normalizes_source_key(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "maps.json"
path.write_text(json.dumps({"global": {}, "sources": {"nested\\map.kmz": {"minzoom": 8}}}))
self.assertIn("nested/map.kmz", build_tiles.read_config(path)["sources"])
def test_publish_replaces_generated_output_directory(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
stage = root / "stage"
output = root / "tiles"
stage.mkdir()
output.mkdir()
(stage / "metadata.json").write_text("new")
(stage / "8").mkdir()
(stage / "8" / "tile.png").write_text("tile")
(output / "stale.png").write_text("stale")
build_tiles.publish(stage, output)
self.assertFalse((output / "stale.png").exists())
self.assertEqual((output / "metadata.json").read_text(), "new")
self.assertTrue((output / "8" / "tile.png").is_file())
def test_empty_tile_is_opaque_white_png(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory)
build_tiles.write_empty_tile(target)
with build_tiles.Image.open(target / "empty.png") as image:
self.assertEqual(image.mode, "RGBA")
self.assertEqual(image.size, (256, 256))
self.assertEqual(image.getpixel((0, 0)), (255, 255, 255, 255))
if __name__ == "__main__":
unittest.main()