1 | // License: GPL. For details, see LICENSE file.
|
---|
2 | package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style;
|
---|
3 |
|
---|
4 | import static org.openstreetmap.josm.tools.I18n.tr;
|
---|
5 |
|
---|
6 | import java.awt.Image;
|
---|
7 | import java.awt.image.BufferedImage;
|
---|
8 | import java.io.BufferedReader;
|
---|
9 | import java.io.File;
|
---|
10 | import java.io.IOException;
|
---|
11 | import java.io.InputStream;
|
---|
12 | import java.io.OutputStream;
|
---|
13 | import java.nio.charset.StandardCharsets;
|
---|
14 | import java.nio.file.Files;
|
---|
15 | import java.util.Collections;
|
---|
16 | import java.util.LinkedHashMap;
|
---|
17 | import java.util.List;
|
---|
18 | import java.util.Map;
|
---|
19 | import java.util.Map.Entry;
|
---|
20 | import java.util.Objects;
|
---|
21 | import java.util.Optional;
|
---|
22 | import java.util.concurrent.ConcurrentHashMap;
|
---|
23 | import java.util.stream.Collectors;
|
---|
24 |
|
---|
25 | import javax.imageio.ImageIO;
|
---|
26 | import javax.json.Json;
|
---|
27 | import javax.json.JsonObject;
|
---|
28 | import javax.json.JsonReader;
|
---|
29 | import javax.json.JsonValue;
|
---|
30 |
|
---|
31 | import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException;
|
---|
32 | import org.openstreetmap.josm.data.preferences.JosmBaseDirectories;
|
---|
33 | import org.openstreetmap.josm.gui.MainApplication;
|
---|
34 | import org.openstreetmap.josm.gui.mappaint.ElemStyles;
|
---|
35 | import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
|
---|
36 | import org.openstreetmap.josm.io.CachedFile;
|
---|
37 | import org.openstreetmap.josm.spi.preferences.Config;
|
---|
38 | import org.openstreetmap.josm.tools.Logging;
|
---|
39 |
|
---|
40 | /**
|
---|
41 | * Create a mapping for a Mapbox Vector Style
|
---|
42 | *
|
---|
43 | * @author Taylor Smock
|
---|
44 | * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/">https://docs.mapbox.com/mapbox-gl-js/style-spec/</a>
|
---|
45 | * @since 17862
|
---|
46 | */
|
---|
47 | public class MapboxVectorStyle {
|
---|
48 |
|
---|
49 | private static final ConcurrentHashMap<String, MapboxVectorStyle> STYLE_MAPPING = new ConcurrentHashMap<>();
|
---|
50 |
|
---|
51 | /**
|
---|
52 | * Get a MapboxVector style for a URL
|
---|
53 | * @param url The url to get
|
---|
54 | * @return The Mapbox Vector Style. May be {@code null} if there was an error.
|
---|
55 | */
|
---|
56 | public static MapboxVectorStyle getMapboxVectorStyle(String url) {
|
---|
57 | return STYLE_MAPPING.computeIfAbsent(url, key -> {
|
---|
58 | try (CachedFile style = new CachedFile(url);
|
---|
59 | BufferedReader reader = style.getContentReader();
|
---|
60 | JsonReader jsonReader = Json.createReader(reader)) {
|
---|
61 | return new MapboxVectorStyle(jsonReader.read().asJsonObject());
|
---|
62 | } catch (IOException e) {
|
---|
63 | Logging.error(e);
|
---|
64 | }
|
---|
65 | // Documentation indicates that this will <i>not</i> be entered into the map, which means that this will be
|
---|
66 | // retried if something goes wrong.
|
---|
67 | return null;
|
---|
68 | });
|
---|
69 | }
|
---|
70 |
|
---|
71 | /** The version for the style specification */
|
---|
72 | private final int version;
|
---|
73 | /** The optional name for the vector style */
|
---|
74 | private final String name;
|
---|
75 | /** The optional URL for sprites. This mush be absolute (so it must contain the scheme, authority, and path). */
|
---|
76 | private final String spriteUrl;
|
---|
77 | /** The optional URL for glyphs. This may have replaceable values in it. */
|
---|
78 | private final String glyphUrl;
|
---|
79 | /** The required collection of sources with a list of layers that are applicable for that source*/
|
---|
80 | private final Map<Source, ElemStyles> sources;
|
---|
81 |
|
---|
82 | /**
|
---|
83 | * Create a new MapboxVector style. You should prefer {@link #getMapboxVectorStyle(String)}
|
---|
84 | * for deduplication purposes.
|
---|
85 | *
|
---|
86 | * @param jsonObject The object to create the style from
|
---|
87 | * @see #getMapboxVectorStyle(String)
|
---|
88 | */
|
---|
89 | public MapboxVectorStyle(JsonObject jsonObject) {
|
---|
90 | // There should be a version specifier. We currently only support version 8.
|
---|
91 | // This can throw an NPE when there is no version number.
|
---|
92 | this.version = jsonObject.getInt("version");
|
---|
93 | if (this.version == 8) {
|
---|
94 | this.name = jsonObject.getString("name", null);
|
---|
95 | String id = jsonObject.getString("id", this.name);
|
---|
96 | this.spriteUrl = jsonObject.getString("sprite", null);
|
---|
97 | this.glyphUrl = jsonObject.getString("glyphs", null);
|
---|
98 | final List<Source> sourceList;
|
---|
99 | if (jsonObject.containsKey("sources") && jsonObject.get("sources").getValueType() == JsonValue.ValueType.OBJECT) {
|
---|
100 | sourceList = jsonObject.getJsonObject("sources").entrySet().stream()
|
---|
101 | .filter(entry -> entry.getValue().getValueType() == JsonValue.ValueType.OBJECT)
|
---|
102 | .map(entry -> {
|
---|
103 | try {
|
---|
104 | return new Source(entry.getKey(), entry.getValue().asJsonObject());
|
---|
105 | } catch (InvalidMapboxVectorTileException e) {
|
---|
106 | Logging.error(e);
|
---|
107 | // Reraise if not a known exception
|
---|
108 | if (!"TileJson not yet supported".equals(e.getMessage())) {
|
---|
109 | throw e;
|
---|
110 | }
|
---|
111 | }
|
---|
112 | return null;
|
---|
113 | }).filter(Objects::nonNull).collect(Collectors.toList());
|
---|
114 | } else {
|
---|
115 | sourceList = Collections.emptyList();
|
---|
116 | }
|
---|
117 | final List<Layers> layers;
|
---|
118 | if (jsonObject.containsKey("layers") && jsonObject.get("layers").getValueType() == JsonValue.ValueType.ARRAY) {
|
---|
119 | layers = jsonObject.getJsonArray("layers").stream()
|
---|
120 | .filter(JsonObject.class::isInstance).map(JsonObject.class::cast).map(obj -> new Layers(id, obj))
|
---|
121 | .collect(Collectors.toList());
|
---|
122 | } else {
|
---|
123 | layers = Collections.emptyList();
|
---|
124 | }
|
---|
125 | final Map<Optional<Source>, List<Layers>> sourceLayer = layers.stream().collect(
|
---|
126 | Collectors.groupingBy(layer -> sourceList.stream().filter(source -> source.getName().equals(layer.getSource()))
|
---|
127 | .findFirst(), LinkedHashMap::new, Collectors.toList()));
|
---|
128 | // Abuse HashMap null (null == default)
|
---|
129 | this.sources = new LinkedHashMap<>();
|
---|
130 | for (Entry<Optional<Source>, List<Layers>> entry : sourceLayer.entrySet()) {
|
---|
131 | final Source source = entry.getKey().orElse(null);
|
---|
132 | final String data = entry.getValue().stream().map(Layers::toString).collect(Collectors.joining());
|
---|
133 | final String metaData = "meta{title:" + (source == null ? "Generated Style" :
|
---|
134 | source.getName()) + ";version:\"autogenerated\";description:\"auto generated style\";}";
|
---|
135 |
|
---|
136 | // This is the default canvas
|
---|
137 | final String canvas = "canvas{default-points:false;default-lines:false;}";
|
---|
138 | final MapCSSStyleSource style = new MapCSSStyleSource(metaData + canvas + data);
|
---|
139 | // Save to directory
|
---|
140 | MainApplication.worker.execute(() -> this.save((source == null ? data.hashCode() : source.getName()) + ".mapcss", style));
|
---|
141 | this.sources.put(source, new ElemStyles(Collections.singleton(style)));
|
---|
142 | }
|
---|
143 | if (this.spriteUrl != null && !this.spriteUrl.trim().isEmpty()) {
|
---|
144 | MainApplication.worker.execute(this::fetchSprites);
|
---|
145 | }
|
---|
146 | } else {
|
---|
147 | throw new IllegalArgumentException(tr("Vector Tile Style Version not understood: version {0} (json: {1})",
|
---|
148 | this.version, jsonObject));
|
---|
149 | }
|
---|
150 | }
|
---|
151 |
|
---|
152 | /**
|
---|
153 | * Fetch sprites. Please note that this is (literally) only png. Unfortunately.
|
---|
154 | * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/</a>
|
---|
155 | */
|
---|
156 | private void fetchSprites() {
|
---|
157 | // HiDPI images first -- if this succeeds, don't bother with the lower resolution (JOSM has no method to switch)
|
---|
158 | try (CachedFile spriteJson = new CachedFile(this.spriteUrl + "@2x.json");
|
---|
159 | CachedFile spritePng = new CachedFile(this.spriteUrl + "@2x.png")) {
|
---|
160 | if (parseSprites(spriteJson, spritePng)) {
|
---|
161 | return;
|
---|
162 | }
|
---|
163 | }
|
---|
164 | try (CachedFile spriteJson = new CachedFile(this.spriteUrl + ".json");
|
---|
165 | CachedFile spritePng = new CachedFile(this.spriteUrl + ".png")) {
|
---|
166 | parseSprites(spriteJson, spritePng);
|
---|
167 | }
|
---|
168 | }
|
---|
169 |
|
---|
170 | private boolean parseSprites(CachedFile spriteJson, CachedFile spritePng) {
|
---|
171 | /* JSON looks like this:
|
---|
172 | * { "image-name": {"width": width, "height": height, "x": x, "y": y, "pixelRatio": 1 }}
|
---|
173 | * width/height are the dimensions of the image
|
---|
174 | * x -- distance right from top left
|
---|
175 | * y -- distance down from top left
|
---|
176 | * pixelRatio -- this <i>appears</i> to be from the "@2x" (default 1)
|
---|
177 | * content -- [left, top corner, right, bottom corner]
|
---|
178 | * stretchX -- [[from, to], [from, to], ...]
|
---|
179 | * stretchY -- [[from, to], [from, to], ...]
|
---|
180 | */
|
---|
181 | final JsonObject spriteObject;
|
---|
182 | final BufferedImage spritePngImage;
|
---|
183 | try (BufferedReader spriteJsonBufferedReader = spriteJson.getContentReader();
|
---|
184 | JsonReader spriteJsonReader = Json.createReader(spriteJsonBufferedReader);
|
---|
185 | InputStream spritePngBufferedReader = spritePng.getInputStream()
|
---|
186 | ) {
|
---|
187 | spriteObject = spriteJsonReader.read().asJsonObject();
|
---|
188 | spritePngImage = ImageIO.read(spritePngBufferedReader);
|
---|
189 | } catch (IOException e) {
|
---|
190 | Logging.error(e);
|
---|
191 | return false;
|
---|
192 | }
|
---|
193 | for (Entry<String, JsonValue> entry : spriteObject.entrySet()) {
|
---|
194 | final JsonObject info = entry.getValue().asJsonObject();
|
---|
195 | int width = info.getInt("width");
|
---|
196 | int height = info.getInt("height");
|
---|
197 | int x = info.getInt("x");
|
---|
198 | int y = info.getInt("y");
|
---|
199 | save(entry.getKey() + ".png", spritePngImage.getSubimage(x, y, width, height));
|
---|
200 | }
|
---|
201 | return true;
|
---|
202 | }
|
---|
203 |
|
---|
204 | private void save(String name, Object object) {
|
---|
205 | final File cache;
|
---|
206 | if (object instanceof Image) {
|
---|
207 | // Images have a specific location where they are looked for
|
---|
208 | cache = new File(Config.getDirs().getUserDataDirectory(true), "images");
|
---|
209 | } else {
|
---|
210 | cache = JosmBaseDirectories.getInstance().getCacheDirectory(true);
|
---|
211 | }
|
---|
212 | final File location = new File(cache, this.name != null ? this.name : Integer.toString(this.hashCode()));
|
---|
213 | if ((!location.exists() && !location.mkdirs()) || (location.exists() && !location.isDirectory())) {
|
---|
214 | // Don't try to save if the file exists and is not a directory or we couldn't create it
|
---|
215 | return;
|
---|
216 | }
|
---|
217 | final File toSave = new File(location, name);
|
---|
218 | Logging.debug("Saving {0} to {1}...", object.getClass().getSimpleName(), toSave);
|
---|
219 | try {
|
---|
220 | if (object instanceof BufferedImage) {
|
---|
221 | // This directory is checked first when getting images
|
---|
222 | if (!ImageIO.write((BufferedImage) object, "png", toSave)) {
|
---|
223 | Logging.warn("No appropriate PNG writer could be found");
|
---|
224 | }
|
---|
225 | } else {
|
---|
226 | try (OutputStream fileOutputStream = Files.newOutputStream(toSave.toPath())) {
|
---|
227 | if (object instanceof String) {
|
---|
228 | fileOutputStream.write(((String) object).getBytes(StandardCharsets.UTF_8));
|
---|
229 | } else if (object instanceof MapCSSStyleSource) {
|
---|
230 | MapCSSStyleSource source = (MapCSSStyleSource) object;
|
---|
231 | try (InputStream inputStream = source.getSourceInputStream()) {
|
---|
232 | int byteVal = inputStream.read();
|
---|
233 | do {
|
---|
234 | fileOutputStream.write(byteVal);
|
---|
235 | byteVal = inputStream.read();
|
---|
236 | } while (byteVal > -1);
|
---|
237 | source.url = "file:/" + toSave.getAbsolutePath().replace('\\', '/');
|
---|
238 | if (source.isLoaded()) {
|
---|
239 | source.loadStyleSource();
|
---|
240 | }
|
---|
241 | }
|
---|
242 | }
|
---|
243 | }
|
---|
244 | }
|
---|
245 | } catch (IOException e) {
|
---|
246 | Logging.warn(e);
|
---|
247 | }
|
---|
248 | }
|
---|
249 |
|
---|
250 | /**
|
---|
251 | * Get the generated layer->style mapping
|
---|
252 | * @return The mapping (use to enable/disable a paint style)
|
---|
253 | */
|
---|
254 | public Map<Source, ElemStyles> getSources() {
|
---|
255 | return this.sources;
|
---|
256 | }
|
---|
257 |
|
---|
258 | /**
|
---|
259 | * Get the sprite url for the style
|
---|
260 | * @return The base sprite url
|
---|
261 | */
|
---|
262 | public String getSpriteUrl() {
|
---|
263 | return this.spriteUrl;
|
---|
264 | }
|
---|
265 |
|
---|
266 | @Override
|
---|
267 | public boolean equals(Object other) {
|
---|
268 | if (other != null && other.getClass() == this.getClass()) {
|
---|
269 | MapboxVectorStyle o = (MapboxVectorStyle) other;
|
---|
270 | return this.version == o.version
|
---|
271 | && Objects.equals(this.name, o.name)
|
---|
272 | && Objects.equals(this.glyphUrl, o.glyphUrl)
|
---|
273 | && Objects.equals(this.spriteUrl, o.spriteUrl)
|
---|
274 | && Objects.equals(this.sources, o.sources);
|
---|
275 | }
|
---|
276 | return false;
|
---|
277 | }
|
---|
278 |
|
---|
279 | @Override
|
---|
280 | public int hashCode() {
|
---|
281 | return Objects.hash(this.name, this.version, this.glyphUrl, this.spriteUrl, this.sources);
|
---|
282 | }
|
---|
283 |
|
---|
284 | @Override
|
---|
285 | public String toString() {
|
---|
286 | return "MapboxVectorStyle [version=" + version + ", " + (name != null ? "name=" + name + ", " : "")
|
---|
287 | + (spriteUrl != null ? "spriteUrl=" + spriteUrl + ", " : "")
|
---|
288 | + (glyphUrl != null ? "glyphUrl=" + glyphUrl + ", " : "")
|
---|
289 | + (sources != null ? "sources=" + sources : "") + "]";
|
---|
290 | }
|
---|
291 | }
|
---|