4dd / m2kml
Metro2 KML to GeoJSON converter
Requires
- php: >=8.4
- ext-dom: *
- ext-libxml: *
- ext-simplexml: *
- symfony/console: ^8.0
- symfony/yaml: ^8.0
Requires (Dev)
- carthage-software/mago: ^1.16.0
README
A PHP CLI tool that converts metro2.org subway map data from Google Earth Pro KML format to GeoJSON.
The Problem It Solves
Metro2 map data is authored in Google Earth Pro and exported as KML files. To publish these on a web map, the KML must go through a series of transformations: normalize the KML for re-editing, apply underground depth values to coordinates, resolve style references, extract display levels, and finally convert to GeoJSON.
What m2kml does
m2kml replaces the entire convert portion of the pipeline with one in-memory pass:
m2kml m2spbm-source.kml m2spbm.geojson
No intermediate files. No Node.js dependency. The KML is parsed once into a PHP object graph, all transformations run in memory, and the result is written directly as GeoJSON.
The normalize command (which prepares KML for continued editing in Google Earth Pro)
is kept as a separate step since it produces a KML file the author needs to keep:
m2kml normalize m2spbm-source.kml [m2spbm.kml]
Installation
Requires PHP 8.4+ and Composer.
Global install (recommended):
composer global require 4dd/m2kml
Make sure ~/.config/composer/vendor/bin (or ~/.composer/vendor/bin) is in your
PATH.
Local install:
composer require 4dd/m2kml
./vendor/bin/m2kml input.kml output.geojson
Usage
Convert KML to GeoJSON:
m2kml input.kml output.geojson
Normalize KML for re-editing in Google Earth Pro:
m2kml normalize input.kml # overwrites input.kml
m2kml normalize input.kml out.kml # writes to a new file
How the Conversion Works
Pipeline overview
KML file
│
▼ KmlParser
KmlDocument (PHP object graph)
│ styles, styleMaps, folder tree with placemarks
│
▼ StyleIndex::build()
StyleIndex (styleUrl → GeoJSON style properties)
│
▼ GeoJsonBuilder::build()
GeoJSON FeatureCollection
│ single recursive tree walk, independent transformers per placemark
│
▼ json_encode()
output.geojson
1. KML Parser
Loads the KML file into a SimpleXML tree and extracts:
- Styles —
LineStyle(color + width) andPolyStyle(color).IconStyleis ignored — GeoJSON has no icon concept. - StyleMaps — only the
normalpair; thehighlightpair is ignored. - Folder tree — recursively parsed into
KmlFolderobjects with nestedKmlFolderandKmlPlacemarkchildren. - Placemarks — name, description, styleUrl, geometry (LineString or Polygon), and ExtendedData key-value pairs.
- Coordinates — parsed from KML's comma-separated
lng,lat,alttuples into PHP float arrays, rounded to 8 decimal places at parse time.
2. Style Index
Built once from the parsed document before the tree walk begins.
KML stores colors in ABGR hex format — the reverse of CSS RGB, with an alpha prefix:
KML: ff 16 16 16
^^ ^^ ^^ ^^
│ │ │ └── red → CSS first pair
│ │ └───── green → CSS second pair
│ └──────── blue → CSS third pair
└─────────── alpha: 0x00=transparent, 0xff=opaque → opacity = value/255
For each Style, the index pre-computes the GeoJSON properties:
stroke, stroke-opacity, stroke-width, fill, fill-opacity.
StyleMaps are resolved at index-build time: each StyleMap entry points to its normal
Style, so StyleIndex::resolve(styleUrl) returns the final properties in one lookup
regardless of whether the URL references a Style or a StyleMap.
3. GeoJSON Builder
A single recursive walk over the folder tree. For each placemark:
Description parsing — The <description> element in metro2 KML files contains
YAML-formatted metadata, for example:
status: 'planed'
color: '#ef4136'
depth: -63
Parsed with symfony/yaml. Scalars remain strings; YAML lists become PHP arrays.
Metadata propagation — A folder's description metadata propagates to all child folders
and placemarks. Any property declared in a parent folder is inherited by its children;
a child's own declaration of the same key takes precedence. This applies to all metadata
keys, including depth and depth_map. depth and depth_map are mutually exclusive —
whichever is declared at the closer scope wins and clears the other.
Depth application — Underground coordinates. Two modes:
- Simple depth (
depth: -63): the Z component of every coordinate is set to that value. - Depth map (
depth_map: '0:-52, 5:-57, 80:-57, 95:-63, 100:-63'): position percentages (0–100) mapped to depth values. The tool calculates cumulative Haversine distances along the line geometry, determines each point's position as a percentage of total length, and linearly interpolates the depth between the declared pivot points. This allows a tunnel to gradually descend or ascend along its route.
The depth is applied directly to the already-parsed float[][] coordinate arrays before they
are written to GeoJSON. Altitude values are rounded to 2 decimal places.
Name transform — Placemark names follow a d{N}description convention where N (0–4)
is a display mode. For example, d1sw0-pocket-track--karetnaya has display
mode 1. The digit is extracted and written as the display property. The name itself
is never included in GeoJSON output.
Property assembly — For each placemark, properties are merged in this order:
- Style properties from StyleIndex (
stroke,stroke-opacity,stroke-width,fill,fill-opacity) displaymode (from name)- Inherited metadata context (all ancestor folder props merged, placemark's own props win)
—
depthanddepth_mapare excluded from output (consumed by depth application) - Pre-existing
ExtendedDatafrom the KML (for files that were already processed) parents— the folder ancestry stack as[{"name": "service-lines"}, {"name": "06"}]
styleUrl and top-level root folder of parents stack are never added to properties.
4. Output
json_encode($collection, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)
Unicode characters and slashes are written as-is (not escaped), which keeps Cyrillic station names readable in the file.
What the Normalize Command Does
Google Earth Pro shows description text as a preview tooltip on each item in the tree
panel. On large files this makes the tree very slow to navigate. The normalize command
adds <Snippet maxLines="0"/> to every Placemark, Folder, and Document element, which
hides the preview. This is the only operation that needs to touch the KML as XML.
The conversion pipeline never needs this — it reads the raw KML file directly and ignores the Snippet element.