#!/usr/bin/env python3 """Extract the color-coded parking geometry from the city's parking-map PDF. pdftocairo emits every street segment as a stroked carrying its own matrix() transform with local coordinates, and every lot as an absolute filled . So: parse the path, flatten curves, push through the path's own matrix, and bucket by the exact colour pdftocairo wrote. Output is GeoJSON-shaped but still in SVG *page* coordinates (y down); the georeferencing step turns that into lat/lon. """ import json import re import sys from xml.etree import ElementTree as ET SVG = sys.argv[1] if len(sys.argv) > 1 else "/tmp/map.svg" OUT = sys.argv[2] if len(sys.argv) > 2 else "map_page_coords.json" # Colours exactly as pdftocairo writes them, mapped to the map legend. COLORS = { "rgb(76.499939%, 76.899719%, 76.098633%)": "no_limit", "rgb(96.899414%, 54.499817%, 3.09906%)": "limit_4h", "rgb(79.998779%, 77.2995%, 25.898743%)": "limit_3h", "rgb(82.699585%, 40.39917%, 79.998779%)": "free_2h", "rgb(45.899963%, 69.799805%, 34.899902%)": "green_lot", } NUM = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?") def parse_matrix(s): """matrix(a,b,c,d,e,f) -> tuple. Identity when absent.""" if not s: return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) m = re.search(r"matrix\s*\(([^)]*)\)", s) if not m: return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) v = [float(x) for x in NUM.findall(m.group(1))] return tuple(v[:6]) def apply(mtx, x, y): a, b, c, d, e, f = mtx return (a * x + c * y + e, b * x + d * y + f) def bezier(p0, p1, p2, p3, steps=8): """Flatten a cubic to points. The map's curves are gentle; 8 is plenty.""" out = [] for i in range(1, steps + 1): t = i / steps u = 1 - t out.append( ( u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0], u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1], ) ) return out def parse_path(d): """Return a list of subpaths [(points, closed)] in the path's local space.""" tokens = re.findall(r"([MmLlHhVvCcSsZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", d) subpaths, pts = [], [] cur = (0.0, 0.0) start = (0.0, 0.0) cmd = None i = 0 flat = [(c, n) for c, n in tokens] def nums(k): nonlocal i vals = [] while len(vals) < k and i < len(flat) and flat[i][1]: vals.append(float(flat[i][1])) i += 1 return vals while i < len(flat): c, n = flat[i] if c: cmd = c i += 1 elif cmd is None: i += 1 continue if cmd in "Zz": if pts: subpaths.append((pts, True)) pts = [] cur = start cmd = None continue if cmd in "Mm": v = nums(2) if len(v) < 2: break if pts: subpaths.append((pts, False)) pts = [] cur = (v[0], v[1]) if cmd == "M" else (cur[0] + v[0], cur[1] + v[1]) start = cur pts = [cur] cmd = "L" if cmd == "M" else "l" elif cmd in "Ll": v = nums(2) if len(v) < 2: break cur = (v[0], v[1]) if cmd == "L" else (cur[0] + v[0], cur[1] + v[1]) pts.append(cur) elif cmd in "Hh": v = nums(1) if not v: break cur = (v[0], cur[1]) if cmd == "H" else (cur[0] + v[0], cur[1]) pts.append(cur) elif cmd in "Vv": v = nums(1) if not v: break cur = (cur[0], v[0]) if cmd == "V" else (cur[0], cur[1] + v[0]) pts.append(cur) elif cmd in "Cc": v = nums(6) if len(v) < 6: break if cmd == "C": p1, p2, p3 = (v[0], v[1]), (v[2], v[3]), (v[4], v[5]) else: p1 = (cur[0] + v[0], cur[1] + v[1]) p2 = (cur[0] + v[2], cur[1] + v[3]) p3 = (cur[0] + v[4], cur[1] + v[5]) pts.extend(bezier(cur, p1, p2, p3)) cur = p3 else: i += 1 if pts: subpaths.append((pts, False)) return subpaths def main(): tree = ET.parse(SVG) root = tree.getroot() ns = "{http://www.w3.org/2000/svg}" features = [] for el in root.iter(ns + "path"): stroke = (el.get("stroke") or "").strip() fill = (el.get("fill") or "").strip() kind = COLORS.get(stroke) or COLORS.get(fill) if not kind: continue is_stroke = stroke in COLORS mtx = parse_matrix(el.get("transform")) width = float(el.get("stroke-width") or 0) for local, closed in parse_path(el.get("d") or ""): world = [apply(mtx, x, y) for x, y in local] if len(world) < 2: continue xs = [p[0] for p in world] ys = [p[1] for p in world] features.append( { "kind": kind, "geom": "line" if is_stroke else "polygon", "closed": closed, "strokeWidth": width, "bbox": [min(xs), min(ys), max(xs), max(ys)], "points": [[round(x, 3), round(y, 3)] for x, y in world], } ) with open(OUT, "w") as fh: json.dump({"viewBox": [0, 0, 491.87, 529.043], "features": features}, fh, indent=1) from collections import Counter print(f"{len(features)} features -> {OUT}") for (k, g), n in sorted(Counter((f["kind"], f["geom"]) for f in features).items()): print(f" {k:10s} {g:8s} {n}") # Where are they? Legend swatches cluster in one corner; real geometry spreads out. print("\nbbox spread by kind:") for k in COLORS.values(): fs = [f for f in features if f["kind"] == k] if not fs: continue print( f" {k:10s} x[{min(f['bbox'][0] for f in fs):7.1f},{max(f['bbox'][2] for f in fs):7.1f}] " f"y[{min(f['bbox'][1] for f in fs):7.1f},{max(f['bbox'][3] for f in fs):7.1f}]" ) main()