-
Notifications
You must be signed in to change notification settings - Fork 6
/
mapgen
executable file
·389 lines (332 loc) · 12.5 KB
/
mapgen
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
#!/usr/bin/env python3
import argparse
import base64
import logging
import mimetypes
import os
import sys
import csscompressor
import htmlmin
import jsmin
import pykwalify.core
import ruamel.yaml as yaml
def error(message):
"""Displays an error message and aborts the program.
Parameters
----------
message : str
Message describing the error.
"""
sys.exit("\n---- ERROR ----\n\n{}\n".format(message))
def marker_error(marker, message):
"""Aborts the program due to errors on a marker's data.
Parameters
----------
message : str
Message describing the error.
"""
error(
"Marker at coordinates ({}, {}):\n\n - {}".format(
*marker["coordinates"], message
)
)
def path_from_script(relative_path):
"""Converts a relative path from the script's location into an
absolute path.
Parameters
----------
relative_path : str
Relative path.
Returns
-------
absolute_path : str
Absolute path built by concatening the script's location and
the input relative path.
"""
return os.path.join(os.path.dirname(__file__), relative_path)
def build_marker_icon(marker, default_marker_settings, icon_type):
"""Builds the icon for a marker.
Parameters
----------
marker : dict
Marker's data.
default_marker_settings : dict
Used-defined default values for a marker's attributes.
icon_type : {"normal", "selected"}
Type of icon to generate.
Returns
-------
data_uri : str
Icon represented as a data URI.
Notes
-----
The value specified for an icon can represent either a path to an
icon file or the name of a built-in icon (e.g. "house"), with the
latter only applying if the icon value does not represent the path
to an actual file.
"""
assert icon_type in ("normal", "selected")
# Icon value determined from the YAML data provided by the user.
icon = (
marker.get("{} icon".format(icon_type))
or marker.get("icon")
or default_marker_settings.get("{} icon".format(icon_type))
or default_marker_settings.get("icon")
or "placeholder"
)
# Icon color (only applicable to built-in icons).
icon_color = (
marker.get("{} icon color".format(icon_type))
or marker.get("icon color")
or default_marker_settings.get("{} icon color".format(icon_type))
or default_marker_settings.get("icon color")
or {"normal": "#1081e0", "selected": "#d30800"}[icon_type]
)
# `icon` can be either the path to a file or the name of a built-in icon.
possible_icon_paths = [
icon,
path_from_script("template/icons/{}.svg".format(icon)),
]
for path in possible_icon_paths:
if not os.path.isfile(path):
continue
mime_type, encoding = mimetypes.guess_type(path)
if mime_type == "image/svg+xml":
with open(path, "r") as svg_file:
icon_bytes = str.encode(
"".join(svg_file.read().splitlines()).replace(
"__MAPGEN__ICON_FILL_COLOR__", icon_color
)
)
elif mime_type and mime_type.startswith("image"):
with open(path, "rb") as icon_file:
icon_bytes = icon_file.read()
else:
break
icon_base64 = base64.b64encode(icon_bytes)
return "data:{};base64,{}".format(mime_type, str(icon_base64, "utf-8"))
marker_error(marker, "Icon '{}' is not valid.".format(icon))
def parse_yaml(yaml_str):
"""Parses and validates YAML data describing a map.
Parses the input YAML data and validates it against the map data
schema (see file `schema.yaml`). If the data fails to validate, the
detected errors are displayed and the program is aborted.
Parameters
----------
yaml_str : str
YAML data describing a map.
Returns
-------
yaml_data : dict
Parsed YAML data.
"""
try:
yaml_data = yaml.safe_load(yaml_str)
with open(path_from_script("schema.yaml")) as schema_yaml:
logging.getLogger("pykwalify.core").disabled = True
validator = pykwalify.core.Core(
source_data=yaml_data,
schema_data=yaml.safe_load(schema_yaml.read()),
)
validator.validate(raise_exception=True)
logging.getLogger("pykwalify.core").disabled = False
return yaml_data
except pykwalify.errors.SchemaError as exception:
error(
"YAML data contains the following error(s):\n\n{}".format(
exception.msg
)
)
def validate_markers(markers):
"""Validates all marker coordinates.
Parameters
----------
markers : list of markers
List containing the data for all markers.
"""
all_coordinates = [marker["coordinates"] for marker in markers]
for marker in markers:
coordinates = marker["coordinates"]
if all_coordinates.count(coordinates) > 1:
marker_error(marker, "Marker's coordinates are not unique.")
latitude, longitude = coordinates
if not -90.0 <= latitude <= 90.0:
marker_error(marker, "Latitude value is not within [-90, 90].")
if not -180.0 <= longitude <= 180.0:
marker_error(marker, "Longitude value is not within [-180, 180].")
def js_map_settings(map_settings):
"""Builds map settings needed for the JavaScript code template .
Attributes
----------
map_settings : dict
User-defined map settings.
Returns
-------
map_settings_js : dict
Adjusted map settings which can be inserted directly into the
JavaScript code template.
"""
return {
**map_settings,
**{
"show zoom control": map_settings.get("show zoom control", "yes"),
"tile provider": map_settings.get(
"tile provider", "OpenStreetMap.Mapnik"
),
"title": map_settings.get("title", "Untitled"),
"zoom control position": map_settings.get(
"zoom control position", "top right"
).replace(" ", ""),
},
}
def js_markers(markers, default_marker_settings):
"""Builds marker data needed for the JavaScript code template.
markers : list of markers
List containing the data for all markers.
default_marker_settings : dict
Used-defined default values for a marker's attributes.
Returns
-------
markers_js : list of markers
Adjusted marker data which can be inserted directly into the
JavaScript code template.
"""
return [
{
"coordinates": marker["coordinates"],
"normal icon dimensions": (
marker.get("normal icon dimensions")
or marker.get("icon dimensions")
or default_marker_settings.get("normal icon dimensions")
or default_marker_settings.get("icon dimensions")
or [40, 40]
),
"normal icon index": marker["__normal icon index__"],
"popup contents": marker.get("popup contents", ""),
"selected icon dimensions": (
marker.get("selected icon dimensions")
or marker.get("icon dimensions")
or default_marker_settings.get("selected icon dimensions")
or default_marker_settings.get("icon dimensions")
or [40, 40]
),
"selected icon index": marker["__selected icon index__"],
}
for marker in markers
]
def generate_html(yaml_str):
"""Generates an HTML page containing a user-defined map.
Parameters
----------
yaml_str : str
YAML data describing a map.
Returns
-------
html : str
HTML string representing a stand-alone page, i.e., a page which
can be opened directly in a browser.
"""
yaml_data = parse_yaml(yaml_str)
map_settings = yaml_data.get("map settings", {})
default_marker_settings = yaml_data.get("default marker settings", {})
markers = yaml_data.get("markers", {})
validate_markers(markers)
# Load the HTML file template.
with open(path_from_script("template/index.html")) as index_html:
html = index_html.read()
# Build the icons for all markers and store each unique icon (no duplicates)
# on `marker_icons`. For each marker, store a reference to its associated
# icon from `marker_icons`. These references are used on the generated
# JavaScript code to prevent multiple copies of the same icon from being
# written to the generated page (thereby significantly reducing its size).
marker_icons = []
for marker in markers:
for icon_type in ("normal", "selected"):
marker_icon = build_marker_icon(
marker, default_marker_settings, icon_type
)
if marker_icon not in marker_icons:
icon_index = len(marker_icons)
marker_icons.append(marker_icon)
else:
icon_index = marker_icons.index(marker_icon)
marker["__{} icon index__".format(icon_type)] = icon_index
# Set up, then insert the contents of mapgen.js into the HTML template.
with open(path_from_script("template/mapgen.js")) as mapgen_js:
html = html.replace(
"__MAPGEN__MAPGEN_JS__",
jsmin.jsmin(
mapgen_js.read()
.replace(
"__MAPGEN__MAP_SETTINGS__",
str(js_map_settings(map_settings)),
)
.replace("__MAPGEN__MARKER_ICONS__", str(marker_icons))
.replace(
"__MAPGEN__MARKERS__",
str(js_markers(markers, default_marker_settings)),
)
),
)
# Insert the (pre-minified) contents of leaflet.css into the HTML template.
with open(path_from_script("template/leaflet.css")) as leaflet_css:
html = html.replace("__MAPGEN__LEAFLET_CSS__", leaflet_css.read())
# Insert the (pre-minified) contents of leaflet.js into the HTML template.
with open(path_from_script("template/leaflet.js")) as leaflet_js:
html = html.replace("__MAPGEN__LEAFLET_JS__", leaflet_js.read())
# Insert the (pre-minified) contents of leaflet-providers.js into the HTML
# template.
with open(
path_from_script("template/leaflet-providers.js")
) as leaflet_provider_js:
html = html.replace(
"__MAPGEN__LEAFLET_PROVIDERS_JS__", leaflet_provider_js.read()
)
# Minify, then insert the contents of mapgen.css into the HTML template.
with open(path_from_script("template/mapgen.css")) as mapgen_css:
html = html.replace(
"__MAPGEN__MAPGEN_CSS__", csscompressor.compress(mapgen_css.read())
)
# Minify, then insert all external CSS files into the HTML template.
external_css = ""
for css_filename in map_settings.get("external css files", []):
try:
with open(css_filename) as css_file:
external_css += css_file.read() + "\n"
except OSError:
error("CSS file '{}' is not valid.".format(css_filename))
html = html.replace(
"__MAPGEN__EXTERNAL_CSS__", csscompressor.compress(external_css)
)
# Minify, then insert all external JavaScript files into the HTML template.
external_js = ""
for js_filename in map_settings.get("external javascript files", []):
try:
with open(js_filename) as js_file:
external_js += js_file.read() + "\n"
except OSError:
error("JavaScript file '{}' is not valid.".format(js_filename))
html = html.replace("__MAPGEN__EXTERNAL_JS__", jsmin.jsmin(external_js))
# Sanity check: all placeholders should have been replaced at this point.
assert "__MAPGEN__" not in html
return htmlmin.minify(html)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"-i",
"--input-file",
type=argparse.FileType("r"),
default=sys.stdin,
help="input file (default: stdin)",
)
parser.add_argument(
"-o",
"--output-file",
type=argparse.FileType("w"),
default=sys.stdout,
help="output file (default: stdout)",
)
namespace = parser.parse_args()
# If the input YAML file is empty, default to an empty marker list.
yaml_input = namespace.input_file.read() or "markers: []"
namespace.output_file.write(generate_html(yaml_input))