#!/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'"
)
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())