geojson-diff 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ """Output formats: text, JSON, Markdown, GeoJSON diff layer and HTML."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from typing import Any
8
+
9
+ from ..core import DiffResult, FeatureChange
10
+
11
+ __all__ = [
12
+ "FORMATS",
13
+ "describe_change",
14
+ "format_area",
15
+ "format_distance",
16
+ "format_value",
17
+ "render",
18
+ ]
19
+
20
+ FORMATS = ("text", "json", "md", "geojson", "html")
21
+
22
+
23
+ def format_value(value: Any, limit: int = 60) -> str:
24
+ """Compact JSON rendering of a property value, truncated to ``limit`` characters."""
25
+ text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
26
+ if len(text) > limit:
27
+ text = text[: limit - 3] + "..."
28
+ return text
29
+
30
+
31
+ def format_distance(value: float | None, units: str = "m") -> str:
32
+ if value is None or not math.isfinite(value):
33
+ return "n/a"
34
+ if units != "m":
35
+ return f"{value:.4g} units"
36
+ if abs(value) >= 1000.0:
37
+ return f"{value / 1000.0:.3g} km"
38
+ if abs(value) >= 1.0:
39
+ return f"{value:.1f} m"
40
+ return f"{value:.3g} m"
41
+
42
+
43
+ def _signed(text: str, value: float) -> str:
44
+ return ("+" if value > 0 else "") + text
45
+
46
+
47
+ def format_area(value: float, units: str = "m", signed: bool = False, sup: bool = False) -> str:
48
+ sq = "²" if sup else "2"
49
+ if units != "m":
50
+ text = f"{value:.4g} units{sq}"
51
+ elif abs(value) >= 1e6:
52
+ text = f"{value / 1e6:.3g} km{sq}"
53
+ elif abs(value) >= 1e4:
54
+ text = f"{value / 1e4:.3g} ha"
55
+ else:
56
+ text = f"{value:.1f} m{sq}"
57
+ return _signed(text, value) if signed else text
58
+
59
+
60
+ def format_length(value: float, units: str = "m", signed: bool = False) -> str:
61
+ text = format_distance(value, units)
62
+ return _signed(text, value) if signed else text
63
+
64
+
65
+ def describe_change(change: FeatureChange, sup: bool = False, arrow: str = "->") -> list[str]:
66
+ """Human-readable detail lines for one change (shared by all text formats)."""
67
+ lines: list[str] = []
68
+ for prop in change.properties:
69
+ if prop.op == "add":
70
+ lines.append(f"+ {prop.name}: {format_value(prop.new)}")
71
+ elif prop.op == "remove":
72
+ lines.append(f"- {prop.name}: {format_value(prop.old)}")
73
+ else:
74
+ line = f"{prop.name}: {format_value(prop.old)} {arrow} {format_value(prop.new)}"
75
+ if prop.type_changed:
76
+ line += f" ({prop.old_type} {arrow} {prop.new_type})"
77
+ lines.append(line)
78
+ geom = change.geometry
79
+ if geom is not None and geom.changed:
80
+ units = geom.units
81
+ if geom.type_changed:
82
+ lines.append(f"geometry type: {geom.old_type} {arrow} {geom.new_type}")
83
+ parts = []
84
+ if geom.hausdorff is not None:
85
+ parts.append(f"Hausdorff {format_distance(geom.hausdorff, units)}")
86
+ if geom.centroid_shift is not None:
87
+ parts.append(f"centroid shift {format_distance(geom.centroid_shift, units)}")
88
+ vertex = []
89
+ if geom.vertices_moved:
90
+ vertex.append(f"{geom.vertices_moved} moved")
91
+ if geom.vertices_added:
92
+ vertex.append(f"{geom.vertices_added} added")
93
+ if geom.vertices_removed:
94
+ vertex.append(f"{geom.vertices_removed} removed")
95
+ if vertex:
96
+ parts.append(
97
+ f"vertices {', '.join(vertex)} ({geom.vertices_old} {arrow} {geom.vertices_new})"
98
+ )
99
+ if geom.area_old or geom.area_new:
100
+ parts.append(f"area {format_area(geom.area_delta, units, True, sup)}")
101
+ if geom.length_old or geom.length_new:
102
+ parts.append(f"length {format_length(geom.length_delta, units, True)}")
103
+ if geom.symmetric_difference_area is not None:
104
+ parts.append(
105
+ f"symmetric difference {format_area(geom.symmetric_difference_area, units, sup=sup)}"
106
+ )
107
+ lines.append("geometry: " + ", ".join(parts))
108
+ if change.status in ("added", "removed"):
109
+ props = change.feature.get("properties") or {}
110
+ shown = [f"{k}: {format_value(v, 40)}" for k, v in list(props.items())[:3]]
111
+ if len(props) > 3:
112
+ shown.append(f"... {len(props) - 3} more")
113
+ lines.extend(shown)
114
+ return lines
115
+
116
+
117
+ def render(result: DiffResult, fmt: str = "text", **kwargs: Any) -> str:
118
+ """Render ``result`` as ``text``, ``json``, ``md``, ``geojson`` or ``html``."""
119
+ fmt = {"markdown": "md", "txt": "text", "htm": "html"}.get(fmt, fmt)
120
+ if fmt == "text":
121
+ from .text import render_text
122
+
123
+ return render_text(result, **kwargs)
124
+ if fmt == "json":
125
+ from .machine import render_json
126
+
127
+ return render_json(result, **kwargs)
128
+ if fmt == "md":
129
+ from .markdown import render_markdown
130
+
131
+ return render_markdown(result, **kwargs)
132
+ if fmt == "geojson":
133
+ from .layer import render_layer
134
+
135
+ return render_layer(result, **kwargs)
136
+ if fmt == "html":
137
+ from .html import render_html
138
+
139
+ return render_html(result, **kwargs)
140
+ raise ValueError(f"unknown format {fmt!r}; choose from {', '.join(FORMATS)}")
141
+
142
+
143
+ def summary_counts(result: DiffResult) -> dict[str, int]:
144
+ return {
145
+ k: result.counts.get(k, 0) for k in ("added", "removed", "modified", "moved", "unchanged")
146
+ }
@@ -0,0 +1,237 @@
1
+ """Self-contained HTML report with a Leaflet before/after map."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import re
8
+ from typing import Any
9
+
10
+ from ..core import STATUSES, DiffResult
11
+ from . import describe_change, format_area, format_length
12
+ from .layer import STYLE, diff_layer
13
+
14
+ __all__ = ["DEFAULT_TILES", "OSM_ATTRIBUTION", "render_html", "script_json"]
15
+
16
+ LEAFLET_VERSION = "1.9.4"
17
+ _LEAFLET_JS = f"https://unpkg.com/leaflet@{LEAFLET_VERSION}/dist/leaflet.js"
18
+ _LEAFLET_CSS = f"https://unpkg.com/leaflet@{LEAFLET_VERSION}/dist/leaflet.css"
19
+ _LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
20
+ _LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
21
+
22
+ DEFAULT_TILES = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
23
+ OSM_ATTRIBUTION = (
24
+ '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
25
+ )
26
+
27
+
28
+ def script_json(value: Any) -> str:
29
+ """JSON that is safe inside ``<script type="application/json">``.
30
+
31
+ ``<``, ``>`` and ``&`` are written as Unicode escapes, so a value such as
32
+ ``</script><script>alert(1)</script>`` cannot terminate the element.
33
+ """
34
+ text = json.dumps(value, ensure_ascii=False, allow_nan=False, default=str)
35
+ return (
36
+ text.replace("&", "\\u0026")
37
+ .replace("<", "\\u003c")
38
+ .replace(">", "\\u003e")
39
+ .replace("
", "\\u2028")
40
+ .replace("
", "\\u2029")
41
+ )
42
+
43
+
44
+ _MARKER = re.compile("@@[A-Z_]+@@")
45
+
46
+
47
+ def _e(value: Any) -> str:
48
+ return html.escape(str(value), quote=True)
49
+
50
+
51
+ _TEMPLATE = """<!DOCTYPE html>
52
+ <html lang="en">
53
+ <head>
54
+ <meta charset="utf-8">
55
+ <meta name="viewport" content="width=device-width, initial-scale=1">
56
+ <meta name="referrer" content="strict-origin-when-cross-origin">
57
+ <title>@@TITLE@@</title>
58
+ <link rel="stylesheet" href="@@LEAFLET_CSS@@" integrity="@@LEAFLET_CSS_SRI@@" crossorigin="">
59
+ <style>
60
+ :root { --fg:#1f2328; --muted:#59636e; --bg:#ffffff; --panel:#f6f8fa; --border:#d1d9e0; }
61
+ @media (prefers-color-scheme: dark) {
62
+ :root { --fg:#e6edf3; --muted:#9198a1; --bg:#0d1117; --panel:#151b23; --border:#3d444d; }
63
+ }
64
+ * { box-sizing: border-box; }
65
+ body { margin:0; font:14px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; color:var(--fg); background:var(--bg); }
66
+ header, main { max-width:1200px; margin:0 auto; padding:16px; }
67
+ h1 { font-size:20px; margin:0 0 4px; word-break:break-all; }
68
+ .meta { color:var(--muted); }
69
+ .cards { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0; }
70
+ .card { background:var(--panel); border:1px solid var(--border); border-radius:6px; padding:8px 12px; min-width:96px; }
71
+ .card b { display:block; font-size:22px; }
72
+ .swatch { display:inline-block; width:10px; height:10px; border-radius:2px; margin-right:6px; }
73
+ #map { height:480px; border:1px solid var(--border); border-radius:6px; }
74
+ .nomap { padding:24px; background:var(--panel); border:1px solid var(--border); border-radius:6px; }
75
+ .table-wrap { overflow-x:auto; }
76
+ table { border-collapse:collapse; width:100%; margin-top:16px; }
77
+ th, td { text-align:left; vertical-align:top; padding:6px 8px; border-bottom:1px solid var(--border); }
78
+ td.details { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; font-size:12px; word-break:break-word; }
79
+ .popup div { font-family:ui-monospace,Consolas,monospace; font-size:12px; }
80
+ footer { color:var(--muted); font-size:12px; padding:16px; text-align:center; }
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <header>
85
+ <h1>@@TITLE@@</h1>
86
+ <div class="meta">@@META@@</div>
87
+ <div class="cards">@@CARDS@@</div>
88
+ </header>
89
+ <main>
90
+ @@MAP@@
91
+ <div class="table-wrap"><table>
92
+ <thead><tr><th>Change</th><th>Feature</th><th>Geometry</th><th>Details</th></tr></thead>
93
+ <tbody>
94
+ @@ROWS@@
95
+ </tbody>
96
+ </table></div>
97
+ </main>
98
+ <footer>Generated by geojson-diff @@VERSION@@</footer>
99
+ <script id="gjd-data" type="application/json">@@DATA@@</script>
100
+ <script src="@@LEAFLET_JS@@" integrity="@@LEAFLET_JS_SRI@@" crossorigin=""></script>
101
+ <script>
102
+ (function () {
103
+ "use strict";
104
+ var data = JSON.parse(document.getElementById("gjd-data").textContent);
105
+ var el = document.getElementById("map");
106
+ if (!el || typeof L === "undefined") { return; }
107
+ var map = L.map(el);
108
+ L.tileLayer(data.tiles, { maxZoom: 19, attribution: data.attribution }).addTo(map);
109
+ function style(f) {
110
+ var p = f.properties, before = p["diff:role"] === "before";
111
+ return { color: p.stroke, weight: before ? 2 : 3, opacity: p["stroke-opacity"],
112
+ fillColor: p.fill, fillOpacity: p["fill-opacity"], dashArray: before ? "5 5" : null };
113
+ }
114
+ function popup(f) {
115
+ var box = document.createElement("div");
116
+ box.className = "popup";
117
+ var head = document.createElement("strong");
118
+ head.textContent = f.properties.title;
119
+ box.appendChild(head);
120
+ (f.properties["diff:details"] || []).forEach(function (line) {
121
+ var row = document.createElement("div");
122
+ row.textContent = line;
123
+ box.appendChild(row);
124
+ });
125
+ return box;
126
+ }
127
+ function layer(role) {
128
+ return L.geoJSON(data.layer, {
129
+ filter: function (f) { return f.properties["diff:role"] === role; },
130
+ style: style,
131
+ pointToLayer: function (f, latlng) {
132
+ var s = style(f);
133
+ s.radius = role === "before" ? 5 : 7;
134
+ s.fillOpacity = role === "before" ? 0.2 : 0.8;
135
+ return L.circleMarker(latlng, s);
136
+ },
137
+ onEachFeature: function (f, lyr) { lyr.bindPopup(popup(f)); }
138
+ });
139
+ }
140
+ var before = layer("before").addTo(map);
141
+ var after = layer("change").addTo(map);
142
+ L.control.layers(null, { "Before (old geometry of moved features)": before,
143
+ "After / added / removed": after }, { collapsed: false }).addTo(map);
144
+ var b = data.bbox;
145
+ if (b) {
146
+ if (b[0] === b[2] && b[1] === b[3]) { map.setView([b[1], b[0]], 17); }
147
+ else { map.fitBounds([[b[1], b[0]], [b[3], b[2]]], { padding: [24, 24], maxZoom: 18 }); }
148
+ } else { map.setView([0, 0], 2); }
149
+ })();
150
+ </script>
151
+ </body>
152
+ </html>
153
+ """
154
+
155
+
156
+ def render_html(
157
+ result: DiffResult,
158
+ title: str = "",
159
+ tiles: str = DEFAULT_TILES,
160
+ attribution: str = OSM_ATTRIBUTION,
161
+ max_rows: int = 2000,
162
+ ) -> str:
163
+ """A single HTML file: summary cards, a Leaflet map and a table of changes.
164
+
165
+ Leaflet is loaded from unpkg with Subresource Integrity; OpenStreetMap
166
+ tiles are attributed. Every data value is HTML-escaped in the table and
167
+ embedded as escaped JSON for the map, whose popups use ``textContent``.
168
+ """
169
+ from .. import __version__
170
+
171
+ heading = title or f"GeoJSON diff: {result.old_name} → {result.new_name}"
172
+ units = result.units
173
+ meta = (
174
+ f"{result.old_count} → {result.new_count} features · matched by "
175
+ f"{', '.join(result.options.strategies())} · tolerance {result.options.tolerance}"
176
+ )
177
+ if result.has_changes:
178
+ meta += (
179
+ f" · area Δ {format_area(result.area_delta, units, True, True)}"
180
+ f" · length Δ {format_length(result.length_delta, units, True)}"
181
+ )
182
+ cards = "".join(
183
+ f'<div class="card"><span class="swatch" style="background:{STYLE[s]}"></span>'
184
+ f"{_e(s)}<b>{result.counts.get(s, 0)}</b></div>"
185
+ for s in STATUSES
186
+ )
187
+ rows: list[str] = []
188
+ changed = result.changed
189
+ for change in changed[:max_rows]:
190
+ details = "<br>".join(_e(d) for d in describe_change(change, sup=True, arrow="→"))
191
+ rows.append(
192
+ "<tr>"
193
+ f'<td><span class="swatch" style="background:{STYLE[change.status]}"></span>'
194
+ f"{_e(change.status)}</td>"
195
+ f"<td>{_e(change.key)}</td>"
196
+ f"<td>{_e(change.geometry_type or '-')}</td>"
197
+ f'<td class="details">{details or "-"}</td>'
198
+ "</tr>"
199
+ )
200
+ if len(changed) > max_rows:
201
+ rows.append(f'<tr><td colspan="4">… {len(changed) - max_rows} more changes</td></tr>')
202
+ if not changed:
203
+ rows.append('<tr><td colspan="4">No feature changes.</td></tr>')
204
+
205
+ layer = diff_layer(result, include_properties=False)
206
+ for feature in layer["features"]:
207
+ props: dict[str, Any] = feature["properties"]
208
+ props["diff:details"] = [d for d in str(props.pop("description", "")).split("; ") if d]
209
+ if units == "m":
210
+ map_html = '<div id="map" role="region" aria-label="Map of changed features"></div>'
211
+ else:
212
+ map_html = (
213
+ '<div class="nomap">Map not shown: the coordinates are not longitude/latitude '
214
+ "(RFC 7946).</div>"
215
+ )
216
+ box = result.bbox
217
+ data = {
218
+ "layer": layer,
219
+ "bbox": list(box) if box else None,
220
+ "tiles": tiles,
221
+ "attribution": attribution,
222
+ }
223
+ replacements = {
224
+ "@@TITLE@@": _e(heading),
225
+ "@@META@@": _e(meta),
226
+ "@@CARDS@@": cards,
227
+ "@@MAP@@": map_html,
228
+ "@@ROWS@@": "\n".join(rows),
229
+ "@@VERSION@@": _e(__version__),
230
+ "@@LEAFLET_CSS@@": _LEAFLET_CSS,
231
+ "@@LEAFLET_CSS_SRI@@": _LEAFLET_CSS_SRI,
232
+ "@@LEAFLET_JS@@": _LEAFLET_JS,
233
+ "@@LEAFLET_JS_SRI@@": _LEAFLET_JS_SRI,
234
+ }
235
+ replacements["@@DATA@@"] = script_json(data)
236
+ # One pass, so marker-like text inside data values is never substituted.
237
+ return _MARKER.sub(lambda m: replacements[m.group(0)], _TEMPLATE)
@@ -0,0 +1,111 @@
1
+ """GeoJSON "diff layer": features styled by change type (simplestyle-spec)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from typing import Any
8
+
9
+ from ..core import DiffResult, FeatureChange
10
+ from . import describe_change
11
+
12
+ __all__ = ["STYLE", "diff_layer", "render_layer"]
13
+
14
+ STYLE: dict[str, str] = {
15
+ "added": "#2e7d32",
16
+ "removed": "#c62828",
17
+ "modified": "#ef6c00",
18
+ "moved": "#1565c0",
19
+ "unchanged": "#9e9e9e",
20
+ "before": "#757575",
21
+ }
22
+ """Colours per change type (and for the ``before`` outline of moved features)."""
23
+
24
+ _SIMPLESTYLE = {
25
+ "title",
26
+ "description",
27
+ "marker-size",
28
+ "marker-symbol",
29
+ "marker-color",
30
+ "stroke",
31
+ "stroke-opacity",
32
+ "stroke-width",
33
+ "fill",
34
+ "fill-opacity",
35
+ }
36
+
37
+
38
+ def _styled(
39
+ change: FeatureChange,
40
+ source: dict[str, Any] | None,
41
+ role: str,
42
+ include_properties: bool,
43
+ ) -> dict[str, Any]:
44
+ color = STYLE["before"] if role == "before" else STYLE[change.status]
45
+ props: dict[str, Any] = {}
46
+ if include_properties and source is not None:
47
+ for key, value in (source.get("properties") or {}).items():
48
+ if key not in _SIMPLESTYLE and not str(key).startswith("diff:"):
49
+ props[key] = value
50
+ details = describe_change(change)
51
+ props.update(
52
+ {
53
+ "diff:status": change.status,
54
+ "diff:role": role,
55
+ "diff:key": change.key,
56
+ "diff:changed": [p.name for p in change.properties],
57
+ "title": f"{change.status}: {change.key}" + (" (before)" if role == "before" else ""),
58
+ "description": "; ".join(details),
59
+ "stroke": color,
60
+ "stroke-width": 1 if role == "before" else 2,
61
+ "stroke-opacity": 0.6 if role == "before" else 1.0,
62
+ "fill": color,
63
+ "fill-opacity": 0.05 if role == "before" else 0.35,
64
+ "marker-color": color,
65
+ "marker-size": "small" if role == "before" else "medium",
66
+ }
67
+ )
68
+ if change.geometry is not None and math.isfinite(change.geometry.hausdorff or math.nan):
69
+ props["diff:hausdorff"] = round(change.geometry.hausdorff or 0.0, 3)
70
+ feature: dict[str, Any] = {
71
+ "type": "Feature",
72
+ "properties": props,
73
+ "geometry": (source or {}).get("geometry"),
74
+ }
75
+ if source is not None and source.get("id") is not None:
76
+ feature["id"] = source["id"] if role == "change" else f"{source['id']}:before"
77
+ return feature
78
+
79
+
80
+ def diff_layer(
81
+ result: DiffResult, include_properties: bool = True, include_unchanged: bool = False
82
+ ) -> dict[str, Any]:
83
+ """Build a FeatureCollection that visualises the diff.
84
+
85
+ Added, modified and moved features carry their new geometry, removed
86
+ features their old one; moved features also get a grey ``before``
87
+ feature. Styling uses the simplestyle-spec properties that GitHub's map
88
+ renderer understands; ``diff:*`` properties describe the change.
89
+ """
90
+ features: list[dict[str, Any]] = []
91
+ for change in result.changes:
92
+ if change.status == "unchanged" and not include_unchanged:
93
+ continue
94
+ if change.status == "removed":
95
+ features.append(_styled(change, change.old, "change", include_properties))
96
+ continue
97
+ if change.status == "moved":
98
+ features.append(_styled(change, change.old, "before", include_properties))
99
+ features.append(_styled(change, change.new, "change", include_properties))
100
+ return {"type": "FeatureCollection", "features": features}
101
+
102
+
103
+ def render_layer(
104
+ result: DiffResult,
105
+ include_properties: bool = True,
106
+ include_unchanged: bool = False,
107
+ indent: int | None = None,
108
+ ) -> str:
109
+ """Serialise :func:`diff_layer` as GeoJSON text."""
110
+ layer = diff_layer(result, include_properties, include_unchanged)
111
+ return json.dumps(layer, indent=indent, ensure_ascii=False) + "\n"
@@ -0,0 +1,20 @@
1
+ """Machine-readable, JSON-Patch-like output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ..core import DiffResult
8
+
9
+ __all__ = ["render_json"]
10
+
11
+
12
+ def render_json(result: DiffResult, include_features: bool = True, indent: int = 2) -> str:
13
+ """Serialise ``result`` as JSON.
14
+
15
+ Property changes use JSON Patch vocabulary (``add`` / ``remove`` /
16
+ ``replace`` with RFC 6901 paths) plus ``old``, ``old_type`` and
17
+ ``new_type`` members; geometry changes add a ``replace`` of
18
+ ``/geometry`` and a ``geometry`` block of metrics.
19
+ """
20
+ return json.dumps(result.to_dict(include_features), indent=indent, ensure_ascii=False) + "\n"
@@ -0,0 +1,143 @@
1
+ """Markdown summary for pull-request comments and CI job summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ..core import DiffResult
8
+ from . import describe_change, format_area, format_length
9
+
10
+ __all__ = ["escape_markdown", "render_markdown"]
11
+
12
+ _ESCAPES = {
13
+ "&": "&amp;",
14
+ "<": "&lt;",
15
+ ">": "&gt;",
16
+ "|": "&#124;",
17
+ "@": "&#64;", # never ping users from data values
18
+ "#": "&#35;", # never auto-link issues from data values
19
+ "\\": "&#92;",
20
+ "`": "&#96;",
21
+ "*": "&#42;",
22
+ "_": "&#95;",
23
+ "[": "&#91;",
24
+ "]": "&#93;",
25
+ "~": "&#126;",
26
+ "!": "&#33;",
27
+ "$": "&#36;",
28
+ }
29
+
30
+
31
+ def escape_markdown(text: str) -> str:
32
+ """Escape ``text`` for a GitHub-flavoured Markdown table cell.
33
+
34
+ Every character with Markdown or HTML meaning becomes an HTML entity and
35
+ line breaks become spaces, so data values cannot inject markup, links,
36
+ mentions or table columns.
37
+ """
38
+ out = "".join(_ESCAPES.get(ch, ch) for ch in text)
39
+ return " ".join(out.splitlines())
40
+
41
+
42
+ _LABELS = {
43
+ "added": "added",
44
+ "removed": "removed",
45
+ "modified": "modified",
46
+ "moved": "moved",
47
+ "unchanged": "unchanged",
48
+ }
49
+
50
+
51
+ def render_markdown(
52
+ result: DiffResult,
53
+ max_rows: int = 100,
54
+ title: str = "",
55
+ include_map: bool = False,
56
+ map_limit: int = 60_000,
57
+ ) -> str:
58
+ """Markdown summary: counts table plus a collapsible table of changes.
59
+
60
+ ``include_map`` appends the diff layer as a ```` ```geojson ```` block,
61
+ which GitHub renders as an interactive map, when it fits in ``map_limit``
62
+ characters.
63
+ """
64
+ c = result.counts
65
+ heading = title or f"GeoJSON diff: {result.old_name} → {result.new_name}"
66
+ lines: list[str] = [f"### {escape_markdown(heading)}", ""]
67
+ total = sum(c.get(s, 0) for s in ("added", "removed", "modified", "moved"))
68
+ strategies = ", ".join(result.options.strategies())
69
+ if total:
70
+ lines.append(
71
+ f"**{total} feature change{'s' if total != 1 else ''}** "
72
+ f"({result.old_count} → {result.new_count} features; matched by "
73
+ f"{escape_markdown(strategies)}; tolerance {result.options.tolerance})"
74
+ )
75
+ else:
76
+ lines.append(
77
+ f"**No feature changes** ({result.old_count} → {result.new_count} features; "
78
+ f"matched by {escape_markdown(strategies)}; tolerance {result.options.tolerance})"
79
+ )
80
+ lines += [
81
+ "",
82
+ "| Added | Removed | Modified | Moved | Unchanged |",
83
+ "|---:|---:|---:|---:|---:|",
84
+ "| "
85
+ + " | ".join(
86
+ str(c.get(s, 0)) for s in ("added", "removed", "modified", "moved", "unchanged")
87
+ )
88
+ + " |",
89
+ "",
90
+ ]
91
+ for warning in result.warnings:
92
+ lines.append(f"> **Warning:** {escape_markdown(warning)}")
93
+ lines.append("")
94
+ if total:
95
+ units = result.units
96
+ box = result.bbox
97
+ facts = []
98
+ if box:
99
+ facts.append("bbox of changes " + ", ".join(f"{v:.6f}" for v in box))
100
+ facts.append(f"area Δ {format_area(result.area_delta, units, True, True)}")
101
+ facts.append(f"length Δ {format_length(result.length_delta, units, True)}")
102
+ lines.append(" · ".join(facts))
103
+ lines.append("")
104
+ changed = result.changed
105
+ opened = " open" if len(changed) <= 20 else ""
106
+ lines.append(f"<details{opened}><summary>Feature changes ({len(changed)})</summary>")
107
+ lines.append("")
108
+ lines.append("| Change | Feature | Geometry | Details |")
109
+ lines.append("|---|---|---|---|")
110
+ for change in changed[:max_rows]:
111
+ details = "<br>".join(
112
+ escape_markdown(d) for d in describe_change(change, sup=True, arrow="→")
113
+ )
114
+ lines.append(
115
+ f"| {_LABELS[change.status]} | {escape_markdown(change.key)} | "
116
+ f"{escape_markdown(change.geometry_type or '-')} | {details or '-'} |"
117
+ )
118
+ if len(changed) > max_rows:
119
+ lines.append(f"| … | {len(changed) - max_rows} more | | |")
120
+ lines.append("")
121
+ lines.append("</details>")
122
+ lines.append("")
123
+ if include_map:
124
+ from .layer import diff_layer
125
+
126
+ layer = json.dumps(diff_layer(result, include_properties=False), ensure_ascii=False)
127
+ # Unicode escapes keep data from closing the fence or carrying markup.
128
+ for char, code in (
129
+ ("`", "\\u0060"),
130
+ ("<", "\\u003c"),
131
+ (">", "\\u003e"),
132
+ ("&", "\\u0026"),
133
+ ):
134
+ layer = layer.replace(char, code)
135
+ if len(layer) <= map_limit:
136
+ lines += ["```geojson", layer, "```", ""]
137
+ else:
138
+ lines += [
139
+ f"_Map omitted: the diff layer exceeds {map_limit} characters._",
140
+ "",
141
+ ]
142
+ lines.append("<sub>Generated by geojson-diff</sub>")
143
+ return "\n".join(lines) + "\n"