001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.imagery.vectortile.mapbox.style; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.Image; 007import java.awt.image.BufferedImage; 008import java.io.BufferedReader; 009import java.io.File; 010import java.io.IOException; 011import java.io.InputStream; 012import java.io.OutputStream; 013import java.nio.charset.StandardCharsets; 014import java.nio.file.Files; 015import java.util.Collections; 016import java.util.LinkedHashMap; 017import java.util.List; 018import java.util.Map; 019import java.util.Map.Entry; 020import java.util.Objects; 021import java.util.Optional; 022import java.util.concurrent.ConcurrentHashMap; 023import java.util.stream.Collectors; 024 025import javax.imageio.ImageIO; 026import javax.json.Json; 027import javax.json.JsonObject; 028import javax.json.JsonReader; 029import javax.json.JsonValue; 030 031import org.openstreetmap.josm.data.imagery.vectortile.mapbox.InvalidMapboxVectorTileException; 032import org.openstreetmap.josm.data.preferences.JosmBaseDirectories; 033import org.openstreetmap.josm.gui.MainApplication; 034import org.openstreetmap.josm.gui.mappaint.ElemStyles; 035import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource; 036import org.openstreetmap.josm.io.CachedFile; 037import org.openstreetmap.josm.spi.preferences.Config; 038import org.openstreetmap.josm.tools.Logging; 039import org.openstreetmap.josm.tools.Utils; 040 041/** 042 * Create a mapping for a Mapbox Vector Style 043 * 044 * @author Taylor Smock 045 * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/">https://docs.mapbox.com/mapbox-gl-js/style-spec/</a> 046 * @since 17862 047 */ 048public class MapboxVectorStyle { 049 050 private static final ConcurrentHashMap<String, MapboxVectorStyle> STYLE_MAPPING = new ConcurrentHashMap<>(); 051 052 /** 053 * Get a MapboxVector style for a URL 054 * @param url The url to get 055 * @return The Mapbox Vector Style. May be {@code null} if there was an error. 056 */ 057 public static MapboxVectorStyle getMapboxVectorStyle(String url) { 058 return STYLE_MAPPING.computeIfAbsent(url, key -> { 059 try (CachedFile style = new CachedFile(url); 060 BufferedReader reader = style.getContentReader(); 061 JsonReader jsonReader = Json.createReader(reader)) { 062 return new MapboxVectorStyle(jsonReader.read().asJsonObject()); 063 } catch (IOException e) { 064 Logging.error(e); 065 } 066 // Documentation indicates that this will <i>not</i> be entered into the map, which means that this will be 067 // retried if something goes wrong. 068 return null; 069 }); 070 } 071 072 /** The version for the style specification */ 073 private final int version; 074 /** The optional name for the vector style */ 075 private final String name; 076 /** The optional URL for sprites. This mush be absolute (so it must contain the scheme, authority, and path). */ 077 private final String spriteUrl; 078 /** The optional URL for glyphs. This may have replaceable values in it. */ 079 private final String glyphUrl; 080 /** The required collection of sources with a list of layers that are applicable for that source*/ 081 private final Map<Source, ElemStyles> sources; 082 083 /** 084 * Create a new MapboxVector style. You should prefer {@link #getMapboxVectorStyle(String)} 085 * for deduplication purposes. 086 * 087 * @param jsonObject The object to create the style from 088 * @see #getMapboxVectorStyle(String) 089 */ 090 public MapboxVectorStyle(JsonObject jsonObject) { 091 // There should be a version specifier. We currently only support version 8. 092 // This can throw an NPE when there is no version number. 093 this.version = jsonObject.getInt("version"); 094 if (this.version == 8) { 095 this.name = jsonObject.getString("name", null); 096 String id = jsonObject.getString("id", this.name); 097 this.spriteUrl = jsonObject.getString("sprite", null); 098 this.glyphUrl = jsonObject.getString("glyphs", null); 099 final List<Source> sourceList; 100 if (jsonObject.containsKey("sources") && jsonObject.get("sources").getValueType() == JsonValue.ValueType.OBJECT) { 101 sourceList = jsonObject.getJsonObject("sources").entrySet().stream() 102 .filter(entry -> entry.getValue().getValueType() == JsonValue.ValueType.OBJECT) 103 .map(entry -> { 104 try { 105 return new Source(entry.getKey(), entry.getValue().asJsonObject()); 106 } catch (InvalidMapboxVectorTileException e) { 107 Logging.error(e); 108 // Reraise if not a known exception 109 if (!"TileJson not yet supported".equals(e.getMessage())) { 110 throw e; 111 } 112 } 113 return null; 114 }).filter(Objects::nonNull).collect(Collectors.toList()); 115 } else { 116 sourceList = Collections.emptyList(); 117 } 118 final List<Layers> layers; 119 if (jsonObject.containsKey("layers") && jsonObject.get("layers").getValueType() == JsonValue.ValueType.ARRAY) { 120 layers = jsonObject.getJsonArray("layers").stream() 121 .filter(JsonObject.class::isInstance).map(JsonObject.class::cast).map(obj -> new Layers(id, obj)) 122 .collect(Collectors.toList()); 123 } else { 124 layers = Collections.emptyList(); 125 } 126 final Map<Optional<Source>, List<Layers>> sourceLayer = layers.stream().collect( 127 Collectors.groupingBy(layer -> sourceList.stream().filter(source -> source.getName().equals(layer.getSource())) 128 .findFirst(), LinkedHashMap::new, Collectors.toList())); 129 // Abuse HashMap null (null == default) 130 this.sources = new LinkedHashMap<>(); 131 for (Entry<Optional<Source>, List<Layers>> entry : sourceLayer.entrySet()) { 132 final Source source = entry.getKey().orElse(null); 133 final String data = entry.getValue().stream().map(Layers::toString).collect(Collectors.joining()); 134 final String metaData = "meta{title:" + (source == null ? "Generated Style" : 135 source.getName()) + ";version:\"autogenerated\";description:\"auto generated style\";}"; 136 137 // This is the default canvas 138 final String canvas = "canvas{default-points:false;default-lines:false;}"; 139 final MapCSSStyleSource style = new MapCSSStyleSource(metaData + canvas + data); 140 // Save to directory 141 MainApplication.worker.execute(() -> this.save((source == null ? data.hashCode() : source.getName()) + ".mapcss", style)); 142 this.sources.put(source, new ElemStyles(Collections.singleton(style))); 143 } 144 if (!Utils.isBlank(this.spriteUrl)) { 145 MainApplication.worker.execute(this::fetchSprites); 146 } 147 } else { 148 throw new IllegalArgumentException(tr("Vector Tile Style Version not understood: version {0} (json: {1})", 149 this.version, jsonObject)); 150 } 151 } 152 153 /** 154 * Fetch sprites. Please note that this is (literally) only png. Unfortunately. 155 * @see <a href="https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/">https://docs.mapbox.com/mapbox-gl-js/style-spec/sprite/</a> 156 */ 157 private void fetchSprites() { 158 // HiDPI images first -- if this succeeds, don't bother with the lower resolution (JOSM has no method to switch) 159 try (CachedFile spriteJson = new CachedFile(this.spriteUrl + "@2x.json"); 160 CachedFile spritePng = new CachedFile(this.spriteUrl + "@2x.png")) { 161 if (parseSprites(spriteJson, spritePng)) { 162 return; 163 } 164 } 165 try (CachedFile spriteJson = new CachedFile(this.spriteUrl + ".json"); 166 CachedFile spritePng = new CachedFile(this.spriteUrl + ".png")) { 167 parseSprites(spriteJson, spritePng); 168 } 169 } 170 171 private boolean parseSprites(CachedFile spriteJson, CachedFile spritePng) { 172 /* JSON looks like this: 173 * { "image-name": {"width": width, "height": height, "x": x, "y": y, "pixelRatio": 1 }} 174 * width/height are the dimensions of the image 175 * x -- distance right from top left 176 * y -- distance down from top left 177 * pixelRatio -- this <i>appears</i> to be from the "@2x" (default 1) 178 * content -- [left, top corner, right, bottom corner] 179 * stretchX -- [[from, to], [from, to], ...] 180 * stretchY -- [[from, to], [from, to], ...] 181 */ 182 final JsonObject spriteObject; 183 final BufferedImage spritePngImage; 184 try (BufferedReader spriteJsonBufferedReader = spriteJson.getContentReader(); 185 JsonReader spriteJsonReader = Json.createReader(spriteJsonBufferedReader); 186 InputStream spritePngBufferedReader = spritePng.getInputStream() 187 ) { 188 spriteObject = spriteJsonReader.read().asJsonObject(); 189 spritePngImage = ImageIO.read(spritePngBufferedReader); 190 } catch (IOException e) { 191 Logging.error(e); 192 return false; 193 } 194 for (Entry<String, JsonValue> entry : spriteObject.entrySet()) { 195 final JsonObject info = entry.getValue().asJsonObject(); 196 int width = info.getInt("width"); 197 int height = info.getInt("height"); 198 int x = info.getInt("x"); 199 int y = info.getInt("y"); 200 save(entry.getKey() + ".png", spritePngImage.getSubimage(x, y, width, height)); 201 } 202 return true; 203 } 204 205 private void save(String name, Object object) { 206 final File cache; 207 if (object instanceof Image) { 208 // Images have a specific location where they are looked for 209 cache = new File(Config.getDirs().getUserDataDirectory(true), "images"); 210 } else { 211 cache = JosmBaseDirectories.getInstance().getCacheDirectory(true); 212 } 213 final File location = new File(cache, this.name != null ? this.name : Integer.toString(this.hashCode())); 214 if ((!location.exists() && !location.mkdirs()) || (location.exists() && !location.isDirectory())) { 215 // Don't try to save if the file exists and is not a directory or we couldn't create it 216 return; 217 } 218 final File toSave = new File(location, name); 219 Logging.debug("Saving {0} to {1}...", object.getClass().getSimpleName(), toSave); 220 try { 221 if (object instanceof BufferedImage) { 222 // This directory is checked first when getting images 223 if (!ImageIO.write((BufferedImage) object, "png", toSave)) { 224 Logging.warn("No appropriate PNG writer could be found"); 225 } 226 } else { 227 try (OutputStream fileOutputStream = Files.newOutputStream(toSave.toPath())) { 228 if (object instanceof String) { 229 fileOutputStream.write(((String) object).getBytes(StandardCharsets.UTF_8)); 230 } else if (object instanceof MapCSSStyleSource) { 231 MapCSSStyleSource source = (MapCSSStyleSource) object; 232 try (InputStream inputStream = source.getSourceInputStream()) { 233 int byteVal = inputStream.read(); 234 do { 235 fileOutputStream.write(byteVal); 236 byteVal = inputStream.read(); 237 } while (byteVal > -1); 238 source.url = "file:/" + toSave.getAbsolutePath().replace('\\', '/'); 239 if (source.isLoaded()) { 240 source.loadStyleSource(); 241 } 242 } 243 } 244 } 245 } 246 } catch (IOException e) { 247 Logging.warn(e); 248 } 249 } 250 251 /** 252 * Get the generated layer->style mapping 253 * @return The mapping (use to enable/disable a paint style) 254 */ 255 public Map<Source, ElemStyles> getSources() { 256 return this.sources; 257 } 258 259 /** 260 * Get the sprite url for the style 261 * @return The base sprite url 262 */ 263 public String getSpriteUrl() { 264 return this.spriteUrl; 265 } 266 267 @Override 268 public boolean equals(Object other) { 269 if (other != null && other.getClass() == this.getClass()) { 270 MapboxVectorStyle o = (MapboxVectorStyle) other; 271 return this.version == o.version 272 && Objects.equals(this.name, o.name) 273 && Objects.equals(this.glyphUrl, o.glyphUrl) 274 && Objects.equals(this.spriteUrl, o.spriteUrl) 275 && Objects.equals(this.sources, o.sources); 276 } 277 return false; 278 } 279 280 @Override 281 public int hashCode() { 282 return Objects.hash(this.name, this.version, this.glyphUrl, this.spriteUrl, this.sources); 283 } 284 285 @Override 286 public String toString() { 287 return "MapboxVectorStyle [version=" + version + ", " + (name != null ? "name=" + name + ", " : "") 288 + (spriteUrl != null ? "spriteUrl=" + spriteUrl + ", " : "") 289 + (glyphUrl != null ? "glyphUrl=" + glyphUrl + ", " : "") 290 + (sources != null ? "sources=" + sources : "") + "]"; 291 } 292}