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}