#!/usr/bin/env python3 from __future__ import annotations import argparse 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 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]: tables = root.find("color-tables") if tables is None: raise RuntimeError("missing color-tables") for table in tables.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: continue 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 _esc(value: str) -> str: return value.replace("&", "&").replace('"', """) def _line_style_svg( 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"line style {name} has no vector") origin = vector.find("origin") if origin is None: raise RuntimeError(f"line style {name} has no origin") unit_to_px = (dpi / 25.4) / 100.0 width = max(1, int(math.ceil(float(vector.attrib["width"]) * unit_to_px))) height = max(1, int(math.ceil(float(vector.attrib["height"]) * unit_to_px))) ox = float(origin.attrib["x"]) oy = float(origin.attrib["y"]) pen_colors = _parse_pen_colors(_text(node, "color-ref"), palette) pen_code = next(iter(pen_colors)) pen_width = 1 nominal_width = max(1.0, math.floor((dpi / 25.4) / 5.0)) current: tuple[float, float] | None = None elements: list[str] = [] polygon_mode = False polygon_circles: list[tuple[tuple[float, float], float, str]] = [] def cv(p: tuple[float, float]) -> tuple[float, float]: return ((p[0] - ox) * unit_to_px, (p[1] - oy) * unit_to_px) 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_code = arg[0] elif cmd == "SW": try: pen_width = max(1, int(float(arg))) except ValueError: pen_width = 1 elif cmd == "PU": pts = _points(arg) if pts: current = pts[-1] elif cmd == "PD": pts = _points(arg) if not pts and current is not None: pts = [(current[0] + 1.0, current[1])] for target in pts: if current is None: current = target continue x1, y1 = cv(current) x2, y2 = cv(target) color = _esc(pen_colors.get(pen_code, next(iter(pen_colors.values())))) stroke = max(1.0, pen_width * nominal_width) elements.append( f'' ) current = target elif cmd == "CI" and current is not None: try: radius = float(arg) * unit_to_px except ValueError: continue cx, cy = cv(current) color = _esc(pen_colors.get(pen_code, next(iter(pen_colors.values())))) if polygon_mode: polygon_circles.append(((cx, cy), radius, color)) else: stroke = max(1.0, pen_width * nominal_width) elements.append( f'' ) elif cmd == "ST": # Symbol transparency command; PLNRTE03 uses ST0 before a filled circle. continue elif cmd == "PM": if arg == "0": polygon_mode = True polygon_circles = [] elif arg == "2": polygon_mode = False elif cmd == "FP": for (cx, cy), radius, color in polygon_circles: elements.append( f'' ) polygon_circles = [] polygon_mode = False else: raise RuntimeError(f"unsupported HPGL {cmd} in line style {name}") svg = ( f'' + "".join(elements) + "" ) return svg, { "name": name, "width": width, "height": height, "dpi": dpi, "color_ref": _text(node, "color-ref"), "description": _text(node, "description"), } def _selected_names(usage_path: Path | None, root: ET.Element) -> list[str]: if usage_path is None: parent = root.find("line-styles") if parent is None: return [] return sorted(_text(n, "name") for n in parent.findall("line-style")) usage = json.loads(usage_path.read_text(encoding="utf-8")) return [str(row[0]) for row in usage.get("complex_line_resource_counts", [])] def main() -> int: parser = argparse.ArgumentParser( description="Compile S-52 HPGL complex line styles into MapLibre sprite patterns." ) parser.add_argument("--chartsymbols", required=True, type=Path) parser.add_argument("--base-sprite-png", required=True, type=Path) parser.add_argument("--base-sprite-json", required=True, type=Path) parser.add_argument("--output-prefix", required=True, type=Path) parser.add_argument("--usage-audit", type=Path) parser.add_argument("--palette", default="DAY_BRIGHT") parser.add_argument("--dpi", default=96.0, type=float) args = parser.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) parent = root.find("line-styles") if parent is None: raise SystemExit("missing line-styles") by_name = {_text(n, "name"): n for n in parent.findall("line-style")} names = _selected_names(args.usage_audit, root) missing = sorted(name for name in names if name not in by_name) if missing: raise SystemExit(f"missing line styles: {missing}") base_json = json.loads(args.base_sprite_json.read_text(encoding="utf-8")) base_width, base_height = _png_size(args.base_sprite_png) args.output_prefix.parent.mkdir(parents=True, exist_ok=True) metadata: dict[str, dict[str, Any]] = {} with tempfile.TemporaryDirectory(prefix="cm95-s52-lc-") as td: tmp = Path(td) pngs: list[Path] = [] y = base_height for index, name in enumerate(names): svg, meta = _line_style_svg(by_name[name], palette, args.dpi) 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, ) w, h = _png_size(png_path) key = f"lc-{name}" if key in base_json: raise RuntimeError(f"sprite key collision: {key}") base_json[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(png_path) out_png = args.output_prefix.with_suffix(".png") out_json = args.output_prefix.with_suffix(".json") command = [ "convert", str(args.base_sprite_png), *[str(p) for p in pngs], "-background", "none", "-gravity", "west", "-append", str(out_png), ] subprocess.run(command, check=True) out_json.write_text( json.dumps(base_json, ensure_ascii=False, indent=2), encoding="utf-8" ) meta_path = args.output_prefix.with_name(args.output_prefix.name + "-line-metadata.json") meta_path.write_text( json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8" ) final_width, final_height = _png_size(out_png) report = { "schema": "navsea.cm95.s52-line-sprite/v1", "palette": args.palette, "dpi": args.dpi, "line_style_count": len(names), "line_styles": names, "base_sprite": {"width": base_width, "height": base_height}, "output_sprite": {"width": final_width, "height": final_height}, "sprite_entry_count": len(base_json), } print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())