#!/usr/bin/env python3 from __future__ import annotations import argparse from collections import Counter import json import math from pathlib import Path import re import shutil import struct import subprocess import tempfile import xml.etree.ElementTree as ET from typing import Any DEFAULT_TARGETS = ("LIGHTS", "LNDMRK", "BCNLAT", "BOYLAT", "TOPMAR") def _text(node: ET.Element, tag: str) -> str: child = node.find(tag) return (child.text or "").strip() if child is not None else "" def _calls(instruction: str, opcode: str) -> list[str]: pattern = re.compile(rf"(?:^|;)\s*{re.escape(opcode)}\(([^)]*)\)") out: list[str] = [] for match in pattern.finditer(instruction or ""): first = match.group(1).split(",", 1)[0].strip().strip("'\"") if first: out.append(first) return out 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 _parse_bitmap(node: ET.Element) -> dict[str, int] | None: bitmap = node.find("bitmap") if bitmap is None: return None loc = bitmap.find("graphics-location") pivot = bitmap.find("pivot") if loc is None or pivot is None: return None return { "x": int(loc.attrib["x"]), "y": int(loc.attrib["y"]), "width": int(bitmap.attrib["width"]), "height": int(bitmap.attrib["height"]), "pivot_x": int(pivot.attrib["x"]), "pivot_y": int(pivot.attrib["y"]), } def _parse_vector(node: ET.Element) -> dict[str, Any] | None: vector = node.find("vector") if vector is None: return None origin = vector.find("origin") pivot = vector.find("pivot") if origin is None or pivot is None: return None return { "width": int(vector.attrib.get("width", "0")), "height": int(vector.attrib.get("height", "0")), "origin_x": float(origin.attrib["x"]), "origin_y": float(origin.attrib["y"]), "pivot_x": float(pivot.attrib["x"]), "pivot_y": float(pivot.attrib["y"]), "hpgl": _text(vector, "HPGL"), } def _parse_symbols(root: ET.Element) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} parent = root.find("symbols") if parent is None: return out for node in parent.findall("symbol"): name = _text(node, "name") if not name: continue candidate = { "name": name, "description": _text(node, "description"), "definition": _text(node, "definition") or None, "color_ref": _text(node, "color-ref") or None, "bitmap": _parse_bitmap(node), "vector": _parse_vector(node), } current = out.get(name) if current is None: out[name] = candidate continue # OpenCPN chartsymbols.xml contains duplicate vector/raster definitions # for several sounding symbols. Prefer a real bitmap when available so # the MapLibre sprite matches the raster symbol library exactly. if current.get("bitmap") is None and candidate.get("bitmap") is not None: out[name] = candidate elif current.get("bitmap") is None and current.get("vector") is None: out[name] = candidate return out def _parse_patterns(root: ET.Element) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} parent = root.find("patterns") if parent is None: return out for node in parent.findall("pattern"): name = _text(node, "name") if not name: continue out[name] = { "name": name, "description": _text(node, "description"), "definition": _text(node, "definition") or None, "filltype": _text(node, "filltype") or None, "spacing": _text(node, "spacing") or None, "color_ref": _text(node, "color-ref") or None, "bitmap": _parse_bitmap(node), } return out def _parse_lookups(root: ET.Element, targets: set[str]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] parent = root.find("lookups") if parent is None: return out for node in parent.findall("lookup"): # OpenCPN matches OBCL case-sensitively. Keep standard TOPMAR # separate from lowercase Inland/extension object names such as topmar. object_class = node.attrib.get("name", "") if object_class not in targets: continue instruction = _text(node, "instruction") out.append({ "id": node.attrib.get("id"), "rcid": node.attrib.get("RCID"), "object_class": object_class, "geometry_type": _text(node, "type"), "display_priority": _text(node, "disp-prio"), "radar_priority": _text(node, "radar-prio"), "table_name": _text(node, "table-name"), "attribute_conditions_raw": [ (x.text or "").strip() for x in node.findall("attrib-code") if (x.text or "").strip() ], "instruction": instruction, "display_category": _text(node, "display-cat"), "comment": _text(node, "comment"), "sy": _calls(instruction, "SY"), "cs": _calls(instruction, "CS"), "ap": _calls(instruction, "AP"), "lc": _calls(instruction, "LC"), }) return out def _parse_color_tables(root: ET.Element) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} parent = root.find("color-tables") if parent is None: return out for node in parent.findall("color-table"): graphics = node.find("graphics-file") out[node.attrib.get("name", "")] = { "graphics_file": graphics.attrib.get("name") if graphics is not None else None, "colors": { c.attrib["name"]: { "r": int(c.attrib["r"]), "g": int(c.attrib["g"]), "b": int(c.attrib["b"]), } for c in node.findall("color") }, } return out def _pen_colors(color_ref: str | None, colors: dict[str, Any]) -> 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: continue rgb = colors.get(token[1:]) if isinstance(rgb, dict): out[token[0]] = ( f"#{int(rgb['r']):02x}{int(rgb['g']):02x}{int(rgb['b']):02x}" ) return out or {"A": "#000000"} def _hpgl_points(text: str) -> list[tuple[float, float]]: nums = [float(x) for x in text.split(",") if x.strip()] return [(nums[i], nums[i + 1]) for i in range(0, len(nums) - 1, 2)] def _vector_symbol_svg( symbol: dict[str, Any], colors: dict[str, Any], *, dpi: float = 96.0, ) -> tuple[str, dict[str, float]]: vector = symbol.get("vector") if not isinstance(vector, dict): raise RuntimeError(f"symbol {symbol.get('name')} has no vector definition") unit = (dpi / 25.4) / 100.0 pen_colors = _pen_colors(symbol.get("color_ref"), colors) pen = next(iter(pen_colors)) pen_width = 1.0 current: tuple[float, float] | None = None primitives: list[tuple[str, Any]] = [] minx = miny = float("inf") maxx = maxy = float("-inf") def include(x: float, y: float) -> None: nonlocal minx, miny, maxx, maxy minx = min(minx, x) miny = min(miny, y) maxx = max(maxx, x) maxy = max(maxy, y) for raw in str(vector.get("hpgl") or "").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 = _hpgl_points(arg) if pts: current = pts[-1] include(*current) elif cmd == "PD": pts = _hpgl_points(arg) for target in pts: if current is None: current = target include(*current) continue primitives.append( ("line", (current, target, pen_colors.get(pen, "#000000"), pen_width)) ) include(*current) include(*target) current = target elif cmd == "CI" and current is not None: radius = float(arg) primitives.append( ("circle", (current, radius, pen_colors.get(pen, "#000000"), pen_width)) ) include(current[0] - radius, current[1] - radius) include(current[0] + radius, current[1] + radius) else: raise RuntimeError( f"unsupported HPGL {cmd} in vector symbol {symbol.get('name')}" ) if not primitives or not math.isfinite(minx) or not math.isfinite(miny): raise RuntimeError(f"vector symbol {symbol.get('name')} rendered no primitives") pad = 1.5 width = max(1, int(math.ceil((maxx - minx) * unit + 2 * pad))) height = max(1, int(math.ceil((maxy - miny) * unit + 2 * pad))) def cv(point: tuple[float, float]) -> tuple[float, float]: return ( (point[0] - minx) * unit + pad, (point[1] - miny) * unit + pad, ) parts: list[str] = [] for kind, data in primitives: if kind == "line": p1, p2, color, width_hpgl = data x1, y1 = cv(p1) x2, y2 = cv(p2) stroke = max(1.0, float(width_hpgl)) parts.append( f'' ) else: center, radius, color, width_hpgl = data cx, cy = cv(center) r = max(0.5, float(radius) * unit) stroke = max(1.0, float(width_hpgl)) parts.append( f'' ) pivot = cv((float(vector["pivot_x"]), float(vector["pivot_y"]))) svg = ( f'' + "".join(parts) + "" ) return svg, { "width": width, "height": height, "pivot_x": pivot[0], "pivot_y": pivot[1], } def _sprite_entry(bitmap: dict[str, int]) -> dict[str, int]: return { "x": bitmap["x"], "y": bitmap["y"], "width": bitmap["width"], "height": bitmap["height"], "pixelRatio": 1, } def _icon_offset(bitmap: dict[str, int]) -> list[float]: return [ bitmap["width"] / 2.0 - bitmap["pivot_x"], bitmap["height"] / 2.0 - bitmap["pivot_y"], ] def _offset_expression(metadata: dict[str, dict[str, Any]]) -> list[Any]: expr: list[Any] = ["match", ["get", "s52_symbol_code"]] for name in sorted(metadata): offset = metadata[name]["maplibre_icon_offset"] expr.extend([name, ["literal", offset]]) expr.append(["literal", [0, 0]]) return expr def _build_style( source_id: str, source_layer: str, sprite_url: str, tiles_template: str, metadata: dict[str, dict[str, Any]], ) -> dict[str, Any]: symbol_names = sorted(metadata) return { "version": 8, "name": "CM95 S-52 MapLibre PoC v1", "sprite": sprite_url, "sources": { source_id: { "type": "vector", "tiles": [tiles_template], "minzoom": 0, "maxzoom": 12, } }, "layers": [{ "id": "s52-static-point-symbols", "type": "symbol", "source": source_id, "source-layer": source_layer, "filter": [ "all", ["has", "s52_symbol_code"], ["match", ["get", "s52_symbol_code"], symbol_names, True, False], ], "layout": { "icon-image": ["get", "s52_symbol_code"], "icon-anchor": "center", "icon-offset": _offset_expression(metadata), "icon-allow-overlap": True, "icon-ignore-placement": True, }, }], } def _load_config(path: Path | None) -> dict[str, Any]: if path is None: return {} return json.loads(path.read_text(encoding="utf-8")) def main() -> int: parser = argparse.ArgumentParser( description="Compile OpenCPN S-52 resources into CM95/MapLibre PoC artifacts." ) parser.add_argument("--s57data", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--config", type=Path) parser.add_argument("--targets") parser.add_argument("--palette") args = parser.parse_args() config = _load_config(args.config) targets_text = args.targets or ",".join(config.get("targets", DEFAULT_TARGETS)) targets = {x.strip().upper() for x in targets_text.split(",") if x.strip()} palette = args.palette or config.get("palette", "DAY_BRIGHT") sprite_scope = str(config.get("sprite_scope", "all")) if sprite_scope not in {"all", "target"}: raise SystemExit(f"invalid sprite_scope: {sprite_scope}") source_id = config.get("source_id", "navsea_delivery") source_layer = config.get("source_layer", "navigation_marks") sprite_url = config.get("sprite_url", "./sprite") tiles_template = config.get("tiles_template", "https://REPLACE-ME/{z}/{x}/{y}.pbf") xml_path = args.s57data / "chartsymbols.xml" if not xml_path.is_file(): raise SystemExit(f"missing chartsymbols.xml: {xml_path}") root = ET.parse(xml_path).getroot() symbols = _parse_symbols(root) patterns = _parse_patterns(root) lookups = _parse_lookups(root, targets) color_tables = _parse_color_tables(root) if palette not in color_tables: raise SystemExit(f"unknown palette {palette}; choices={sorted(color_tables)}") atlas_name = color_tables[palette].get("graphics_file") if not atlas_name: raise SystemExit(f"palette {palette} has no graphics-file") atlas_path = args.s57data / atlas_name if not atlas_path.is_file(): raise SystemExit(f"missing atlas: {atlas_path}") atlas_width, atlas_height = _png_size(atlas_path) direct_symbols = sorted({name for row in lookups for name in row["sy"]}) direct_patterns = sorted({name for row in lookups for name in row["ap"]}) csp_names = sorted({name for row in lookups for name in row["cs"]}) sprite_symbols = ( sorted(symbols) if sprite_scope == "all" else direct_symbols ) sprite_patterns = ( sorted(name for name, pattern in patterns.items() if pattern.get("bitmap") is not None) if sprite_scope == "all" else direct_patterns ) metadata: dict[str, dict[str, Any]] = {} pattern_metadata: dict[str, dict[str, Any]] = {} missing_symbols: list[str] = [] vector_only_symbols: list[str] = [] vector_render_names: list[str] = [] out_of_bounds_symbols: list[str] = [] for name in sprite_symbols: symbol = symbols.get(name) if symbol is None: missing_symbols.append(name) continue bitmap = symbol.get("bitmap") if bitmap is None: vector_only_symbols.append(name) if symbol.get("vector") is not None: vector_render_names.append(name) continue if ( bitmap["x"] < 0 or bitmap["y"] < 0 or bitmap["x"] + bitmap["width"] > atlas_width or bitmap["y"] + bitmap["height"] > atlas_height ): out_of_bounds_symbols.append(name) continue metadata[name] = { **symbol, "maplibre_icon_offset": _icon_offset(bitmap), } for name in sprite_patterns: pattern = patterns.get(name) if pattern is None: continue bitmap = pattern.get("bitmap") if bitmap is None: continue if ( bitmap["x"] < 0 or bitmap["y"] < 0 or bitmap["x"] + bitmap["width"] > atlas_width or bitmap["y"] + bitmap["height"] > atlas_height ): continue pattern_metadata[name] = dict(pattern) output = args.output output.mkdir(parents=True, exist_ok=True) sprite_json = { name: _sprite_entry(metadata[name]["bitmap"]) for name in sorted(metadata) } sprite_json.update({ name: _sprite_entry(pattern_metadata[name]["bitmap"]) for name in sorted(pattern_metadata) }) if vector_render_names: if shutil.which("convert") is None: raise SystemExit("ImageMagick convert is required for vector-only S-52 symbols") colors = color_tables[palette].get("colors") or {} with tempfile.TemporaryDirectory(prefix="cm95-s52-symbol-") as td: tmp = Path(td) pngs: list[Path] = [] y = atlas_height for index, name in enumerate(vector_render_names): symbol = symbols[name] svg, vector_meta = _vector_symbol_svg(symbol, colors) svg_path = tmp / f"{index:03d}-{name}.svg" png_path = tmp / f"{index:03d}-{name}.png" svg_path.write_text(svg, encoding="utf-8") subprocess.run( ["convert", "-background", "none", str(svg_path), str(png_path)], check=True, ) width, height = _png_size(png_path) bitmap = { "x": 0, "y": y, "width": width, "height": height, "pivot_x": int(round(vector_meta["pivot_x"])), "pivot_y": int(round(vector_meta["pivot_y"])), } materialized = { **symbol, "bitmap": bitmap, "maplibre_icon_offset": _icon_offset(bitmap), "materialized_from_vector": True, } metadata[name] = materialized sprite_json[name] = _sprite_entry(bitmap) y += height pngs.append(png_path) subprocess.run( [ "convert", str(atlas_path), *[str(p) for p in pngs], "-background", "none", "-gravity", "west", "-append", str(output / "sprite.png"), ], check=True, ) else: shutil.copy2(atlas_path, output / "sprite.png") collisions = sorted(set(metadata) & set(pattern_metadata)) if collisions: raise SystemExit(f"sprite symbol/pattern name collision: {collisions}") (output / "sprite.json").write_text( json.dumps(sprite_json, ensure_ascii=False, indent=2), encoding="utf-8" ) (output / "s52_symbol_metadata.json").write_text( json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8" ) (output / "s52_pattern_metadata.json").write_text( json.dumps(pattern_metadata, ensure_ascii=False, indent=2), encoding="utf-8" ) (output / "s52_target_lookups.json").write_text( json.dumps(lookups, ensure_ascii=False, indent=2), encoding="utf-8" ) (output / "s52_colors.json").write_text( json.dumps(color_tables, ensure_ascii=False, indent=2), encoding="utf-8" ) conditional_rows = [row for row in lookups if row["cs"]] static_rows = [row for row in lookups if row["sy"] and not row["cs"]] (output / "s52_csp_inventory.json").write_text( json.dumps(conditional_rows, ensure_ascii=False, indent=2), encoding="utf-8" ) style = _build_style( source_id, source_layer, sprite_url, tiles_template, metadata ) (output / "style.poc.json").write_text( json.dumps(style, ensure_ascii=False, indent=2), encoding="utf-8" ) by_class = Counter(row["object_class"] for row in lookups) direct_missing = sorted(name for name in direct_symbols if name not in symbols) report = { "schema": "navsea.cm95.s52-maplibre-poc/v1", "targets": sorted(targets), "palette": palette, "atlas": {"file": atlas_name, "width": atlas_width, "height": atlas_height}, "lookup_count": len(lookups), "lookup_count_by_object_class": dict(sorted(by_class.items())), "static_direct_symbol_lookup_count": len(static_rows), "conditional_lookup_count": len(conditional_rows), "conditional_procedures": csp_names, "direct_symbol_reference_count": len(direct_symbols), "direct_pattern_reference_count": len(direct_patterns), "sprite_scope": sprite_scope, "sprite_symbol_entry_count": len(metadata), "sprite_pattern_entry_count": len(pattern_metadata), "sprite_entry_count": len(sprite_json), "missing_symbol_definitions": direct_missing, "vector_only_symbol_definitions": vector_only_symbols, "atlas_out_of_bounds": out_of_bounds_symbols, "production_boundary": { "static_lookup_matching": "materialize_in_cm95_builder", "pbf_symbol_field": "s52_symbol_code", "conditional_symbology": "explicit_builder_derived_or_runtime_renderer", "formal_release": "not_modified_by_this_poc", }, } (output / "audit.json").write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8" ) print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())