#!/usr/bin/env python3 from __future__ import annotations import argparse import json import math from pathlib import Path import shutil import struct import subprocess import tempfile import xml.etree.ElementTree as ET from typing import Any def _png_size(path: Path) -> tuple[int, int]: data = path.read_bytes()[:24] if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n": raise RuntimeError(f"not a PNG: {path}") return struct.unpack(">II", data[16:24]) def _text(node: ET.Element, tag: str) -> str: child = node.find(tag) return (child.text or "").strip() if child is not None else "" def _parse_palette(root: ET.Element, palette: str) -> dict[str, str]: parent = root.find("color-tables") if parent is None: raise RuntimeError("missing color-tables") for table in parent.findall("color-table"): if table.attrib.get("name") != palette: continue return { c.attrib["name"]: f"#{int(c.attrib['r']):02x}{int(c.attrib['g']):02x}{int(c.attrib['b']):02x}" for c in table.findall("color") } raise RuntimeError(f"unknown palette: {palette}") def _parse_pen_colors(color_ref: str, palette: dict[str, str]) -> dict[str, str]: compact = "".join(str(color_ref or "").split()) out: dict[str, str] = {} for i in range(0, len(compact), 6): token = compact[i:i + 6] if len(token) == 6: out[token[0]] = palette.get(token[1:], "#000000") return out or {"A": "#000000"} def _points(text: str) -> list[tuple[float, float]]: nums = [float(x) for x in text.split(",") if x != ""] return [(nums[i], nums[i + 1]) for i in range(0, len(nums) - 1, 2)] def _svg_for_pattern(node: ET.Element, palette: dict[str, str], dpi: float) -> tuple[str, dict[str, Any]]: name = _text(node, "name") vector = node.find("vector") if vector is None: raise RuntimeError(f"pattern {name} has no vector") origin = vector.find("origin") if origin is None: raise RuntimeError(f"pattern {name} has no origin") unit = (dpi / 25.4) / 100.0 width = max(1, int(math.ceil(float(vector.attrib.get("width", "0")) * unit))) height = max(1, int(math.ceil(float(vector.attrib.get("height", "0")) * unit))) ox = float(origin.attrib["x"]) oy = float(origin.attrib["y"]) pen_colors = _parse_pen_colors(_text(node, "color-ref"), palette) pen = next(iter(pen_colors)) pen_width = 1.0 nominal = max(1.0, math.floor((dpi / 25.4) / 5.0)) current: tuple[float, float] | None = None elements: list[str] = [] def cv(p: tuple[float, float]) -> tuple[float, float]: return ((p[0] - ox) * unit, (p[1] - oy) * unit) for raw in _text(node, "HPGL").split(";"): raw = raw.strip() if not raw: continue cmd, arg = raw[:2], raw[2:] if cmd == "SP" and arg: pen = arg[0] elif cmd == "SW": try: pen_width = max(1.0, float(arg)) except ValueError: pen_width = 1.0 elif cmd == "PU": pts = _points(arg) if pts: current = pts[-1] elif cmd == "PD": targets = _points(arg) if not targets and current is not None: # OpenCPN RenderFromHPGL draws an argument-less PD as a # one-screen-pixel horizontal segment, then advances the # current point to that segment end. DRGARE01 relies on it. x1, y1 = cv(current) color = pen_colors.get(pen, next(iter(pen_colors.values()))) stroke = max(1.0, pen_width * nominal) elements.append( f'' ) current = (current[0] + (1.0 / unit), current[1]) for target in targets: if current is None: current = target continue x1, y1 = cv(current) x2, y2 = cv(target) color = pen_colors.get(pen, next(iter(pen_colors.values()))) stroke = max(1.0, pen_width * nominal) elements.append( f'' ) current = target elif cmd == "CI" and current is not None: try: radius = float(arg) * unit except ValueError: continue cx, cy = cv(current) color = pen_colors.get(pen, next(iter(pen_colors.values()))) stroke = max(1.0, pen_width * nominal) elements.append( f'' ) else: raise RuntimeError(f"unsupported HPGL {cmd} in pattern {name}") svg = ( f'' + "".join(elements) + "" ) return svg, { "name": name, "width": width, "height": height, "filltype": _text(node, "filltype"), "spacing": _text(node, "spacing"), "color_ref": _text(node, "color-ref"), "description": _text(node, "description"), } def main() -> int: ap = argparse.ArgumentParser(description="Compile used S-52 vector AP patterns into a MapLibre sprite.") ap.add_argument("--chartsymbols", required=True, type=Path) ap.add_argument("--base-sprite-png", required=True, type=Path) ap.add_argument("--base-sprite-json", required=True, type=Path) ap.add_argument("--usage-audit", type=Path) ap.add_argument( "--all-patterns", action="store_true", help="Compile every vector S-52 pattern in chartsymbols.xml; production resource bundles should use this.", ) ap.add_argument( "--usage-pbf", type=Path, help="Optionally union materialized s52_area_pattern_code values from a PBF tile.", ) ap.add_argument("--output-prefix", required=True, type=Path) ap.add_argument("--palette", default="DAY_BRIGHT") ap.add_argument("--dpi", default=96.0, type=float) args = ap.parse_args() if shutil.which("convert") is None: raise SystemExit("ImageMagick convert is required") root = ET.parse(args.chartsymbols).getroot() palette = _parse_palette(root, args.palette) patterns = root.find("patterns") if patterns is None: raise SystemExit("missing patterns") by_name = {_text(n, "name"): n for n in patterns.findall("pattern")} usage = ( json.loads(args.usage_audit.read_text(encoding="utf-8")) if args.usage_audit is not None else {} ) lookup_used = {str(row[0]) for row in usage.get("pattern_resource_counts", [])} pbf_used: set[str] = set() if args.usage_pbf is not None: try: import mapbox_vector_tile except ImportError as exc: raise SystemExit( "--usage-pbf requires mapbox_vector_tile; run with the project .venv Python" ) from exc decoded = mapbox_vector_tile.decode(args.usage_pbf.read_bytes()) for layer in decoded.values(): for feature in layer.get("features") or []: value = (feature.get("properties") or {}).get("s52_area_pattern_code") if value not in (None, ""): pbf_used.add(str(value)) used = sorted(lookup_used | pbf_used) selected = sorted(by_name) if args.all_patterns else used names = [ name for name in selected if name in by_name and by_name[name].find("vector") is not None ] missing = [name for name in selected if name not in by_name] if missing: raise SystemExit(f"missing patterns: {missing}") sprite = json.loads(args.base_sprite_json.read_text(encoding="utf-8")) base_w, base_h = _png_size(args.base_sprite_png) metadata: dict[str, dict[str, Any]] = {} args.output_prefix.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="cm95-s52-ap-") as td: tmp = Path(td) pngs: list[Path] = [] y = base_h for idx, name in enumerate(names): svg, meta = _svg_for_pattern(by_name[name], palette, args.dpi) sp = tmp / f"{idx:03d}-{name}.svg" pp = tmp / f"{idx:03d}-{name}.png" sp.write_text(svg, encoding="utf-8") subprocess.run(["convert", "-background", "none", str(sp), str(pp)], check=True) alpha_probe = subprocess.run( [ "convert", str(pp), "-alpha", "extract", "-format", "%[fx:maxima]", "info:", ], check=True, capture_output=True, text=True, ).stdout.strip() if not alpha_probe or float(alpha_probe) <= 0.0: raise RuntimeError(f"pattern {name} rendered fully transparent") w, h = _png_size(pp) key = f"ap-{name}" sprite[key] = {"x": 0, "y": y, "width": w, "height": h, "pixelRatio": 1} meta.update({"sprite_key": key, "x": 0, "y": y}) metadata[name] = meta y += h pngs.append(pp) out_png = args.output_prefix.with_suffix(".png") out_json = args.output_prefix.with_suffix(".json") subprocess.run([ "convert", str(args.base_sprite_png), *[str(p) for p in pngs], "-background", "none", "-gravity", "west", "-append", str(out_png) ], check=True) out_json.write_text(json.dumps(sprite, ensure_ascii=False, indent=2), encoding="utf-8") args.output_prefix.with_name(args.output_prefix.name + "-pattern-metadata.json").write_text( json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8" ) w, h = _png_size(out_png) print(json.dumps({ "schema": "navsea.cm95.s52-vector-pattern-sprite/v1", "palette": args.palette, "lookup_usage_pattern_count": len(lookup_used), "pbf_usage_pattern_count": len(pbf_used), "pbf_usage_patterns": sorted(pbf_used), "effective_usage_pattern_count": len(used), "vector_pattern_count": len(names), "vector_patterns": names, "base_sprite": {"width": base_w, "height": base_h}, "output_sprite": {"width": w, "height": h}, "sprite_entry_count": len(sprite), }, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())