001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.imagery;
003
004import static java.nio.charset.StandardCharsets.UTF_8;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.io.File;
008import java.io.IOException;
009import java.io.InputStream;
010import java.net.MalformedURLException;
011import java.net.URL;
012import java.nio.file.InvalidPathException;
013import java.util.ArrayList;
014import java.util.Collection;
015import java.util.Collections;
016import java.util.HashSet;
017import java.util.List;
018import java.util.Map;
019import java.util.Set;
020import java.util.concurrent.ConcurrentHashMap;
021import java.util.function.UnaryOperator;
022import java.util.regex.Pattern;
023import java.util.stream.Collectors;
024
025import javax.imageio.ImageIO;
026import javax.xml.namespace.QName;
027import javax.xml.stream.XMLStreamException;
028import javax.xml.stream.XMLStreamReader;
029
030import org.openstreetmap.josm.data.Bounds;
031import org.openstreetmap.josm.data.coor.EastNorth;
032import org.openstreetmap.josm.data.imagery.DefaultLayer;
033import org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper;
034import org.openstreetmap.josm.data.imagery.ImageryInfo;
035import org.openstreetmap.josm.data.imagery.LayerDetails;
036import org.openstreetmap.josm.data.projection.Projection;
037import org.openstreetmap.josm.data.projection.Projections;
038import org.openstreetmap.josm.io.CachedFile;
039import org.openstreetmap.josm.tools.Logging;
040import org.openstreetmap.josm.tools.Utils;
041
042/**
043 * This class represents the capabilities of a WMS imagery server.
044 */
045public class WMSImagery {
046
047    private static final String CAPABILITIES_QUERY_STRING = "SERVICE=WMS&REQUEST=GetCapabilities";
048
049    /**
050     * WMS namespace address
051     */
052    public static final String WMS_NS_URL = "http://www.opengis.net/wms";
053
054    // CHECKSTYLE.OFF: SingleSpaceSeparator
055    // WMS 1.0 - 1.3.0
056    private static final QName CAPABILITITES_ROOT_130 = new QName("WMS_Capabilities", WMS_NS_URL);
057    private static final QName QN_ABSTRACT            = new QName(WMS_NS_URL, "Abstract");
058    private static final QName QN_CAPABILITY          = new QName(WMS_NS_URL, "Capability");
059    private static final QName QN_CRS                 = new QName(WMS_NS_URL, "CRS");
060    private static final QName QN_DCPTYPE             = new QName(WMS_NS_URL, "DCPType");
061    private static final QName QN_FORMAT              = new QName(WMS_NS_URL, "Format");
062    private static final QName QN_GET                 = new QName(WMS_NS_URL, "Get");
063    private static final QName QN_GETMAP              = new QName(WMS_NS_URL, "GetMap");
064    private static final QName QN_HTTP                = new QName(WMS_NS_URL, "HTTP");
065    private static final QName QN_LAYER               = new QName(WMS_NS_URL, "Layer");
066    private static final QName QN_NAME                = new QName(WMS_NS_URL, "Name");
067    private static final QName QN_REQUEST             = new QName(WMS_NS_URL, "Request");
068    private static final QName QN_SERVICE             = new QName(WMS_NS_URL, "Service");
069    private static final QName QN_STYLE               = new QName(WMS_NS_URL, "Style");
070    private static final QName QN_TITLE               = new QName(WMS_NS_URL, "Title");
071    private static final QName QN_BOUNDINGBOX         = new QName(WMS_NS_URL, "BoundingBox");
072    private static final QName QN_EX_GEOGRAPHIC_BBOX  = new QName(WMS_NS_URL, "EX_GeographicBoundingBox");
073    private static final QName QN_WESTBOUNDLONGITUDE  = new QName(WMS_NS_URL, "westBoundLongitude");
074    private static final QName QN_EASTBOUNDLONGITUDE  = new QName(WMS_NS_URL, "eastBoundLongitude");
075    private static final QName QN_SOUTHBOUNDLATITUDE  = new QName(WMS_NS_URL, "southBoundLatitude");
076    private static final QName QN_NORTHBOUNDLATITUDE  = new QName(WMS_NS_URL, "northBoundLatitude");
077    private static final QName QN_ONLINE_RESOURCE     = new QName(WMS_NS_URL, "OnlineResource");
078
079    // WMS 1.1 - 1.1.1
080    private static final QName CAPABILITIES_ROOT_111 = new QName("WMT_MS_Capabilities");
081    private static final QName QN_SRS                = new QName("SRS");
082    private static final QName QN_LATLONBOUNDINGBOX  = new QName("LatLonBoundingBox");
083
084    // CHECKSTYLE.ON: SingleSpaceSeparator
085
086    /**
087     * An exception that is thrown if there was an error while getting the capabilities of the WMS server.
088     */
089    public static class WMSGetCapabilitiesException extends Exception {
090        private final String incomingData;
091
092        /**
093         * Constructs a new {@code WMSGetCapabilitiesException}
094         * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method)
095         * @param incomingData the answer from WMS server
096         */
097        public WMSGetCapabilitiesException(Throwable cause, String incomingData) {
098            super(cause);
099            this.incomingData = incomingData;
100        }
101
102        /**
103         * Constructs a new {@code WMSGetCapabilitiesException}
104         * @param message   the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method
105         * @param incomingData the answer from the server
106         * @since 10520
107         */
108        public WMSGetCapabilitiesException(String message, String incomingData) {
109            super(message);
110            this.incomingData = incomingData;
111        }
112
113        /**
114         * The data that caused this exception.
115         * @return The server response to the capabilities request.
116         */
117        public String getIncomingData() {
118            return incomingData;
119        }
120    }
121
122    private final Map<String, String> headers = new ConcurrentHashMap<>();
123    private String version = "1.1.1"; // default version
124    private String getMapUrl;
125    private URL capabilitiesUrl;
126    private final List<String> formats = new ArrayList<>();
127    private List<LayerDetails> layers = new ArrayList<>();
128
129    private String title;
130
131    /**
132     * Make getCapabilities request towards given URL
133     * @param url service url
134     * @throws IOException when connection error when fetching get capabilities document
135     * @throws WMSGetCapabilitiesException when there are errors when parsing get capabilities document
136     * @throws InvalidPathException if a Path object cannot be constructed for the capabilities cached file
137     */
138    public WMSImagery(String url) throws IOException, WMSGetCapabilitiesException {
139        this(url, null);
140    }
141
142    /**
143     * Make getCapabilities request towards given URL using headers
144     * @param url service url
145     * @param headers HTTP headers to be sent with request
146     * @throws IOException when connection error when fetching get capabilities document
147     * @throws WMSGetCapabilitiesException when there are errors when parsing get capabilities document
148     * @throws InvalidPathException if a Path object cannot be constructed for the capabilities cached file
149     */
150    public WMSImagery(String url, Map<String, String> headers) throws IOException, WMSGetCapabilitiesException {
151        if (headers != null) {
152            this.headers.putAll(headers);
153        }
154
155        IOException savedExc = null;
156        String workingAddress = null;
157        url_search:
158        for (String z: new String[]{
159                normalizeUrl(url),
160                url,
161                url + CAPABILITIES_QUERY_STRING,
162        }) {
163            for (String ver: new String[]{"", "&VERSION=1.3.0", "&VERSION=1.1.1"}) {
164                try {
165                    attemptGetCapabilities(z + ver);
166                    workingAddress = z;
167                    calculateChildren();
168                    // clear saved exception - we've got something working
169                    savedExc = null;
170                    break url_search;
171                } catch (IOException e) {
172                    savedExc = e;
173                    Logging.warn(e);
174                }
175            }
176        }
177
178        if (workingAddress != null) {
179            try {
180                capabilitiesUrl = new URL(workingAddress);
181            } catch (MalformedURLException e) {
182                if (savedExc == null) {
183                    savedExc = e;
184                }
185                try {
186                    capabilitiesUrl = new File(workingAddress).toURI().toURL();
187                } catch (MalformedURLException e1) { // NOPMD
188                    // do nothing, raise original exception
189                    Logging.trace(e1);
190                }
191            }
192        }
193
194        if (savedExc != null) {
195            throw savedExc;
196        }
197    }
198
199    private void calculateChildren() {
200        Map<LayerDetails, List<LayerDetails>> layerChildren = layers.stream()
201                .filter(x -> x.getParent() != null) // exclude top-level elements
202                .collect(Collectors.groupingBy(LayerDetails::getParent));
203        for (LayerDetails ld: layers) {
204            if (layerChildren.containsKey(ld)) {
205                ld.setChildren(layerChildren.get(ld));
206            }
207        }
208        // leave only top-most elements in the list
209        layers = layers.stream().filter(x -> x.getParent() == null).collect(Collectors.toCollection(ArrayList::new));
210    }
211
212    /**
213     * Returns the list of top-level layers.
214     * @return the list of top-level layers
215     */
216    public List<LayerDetails> getLayers() {
217        return Collections.unmodifiableList(layers);
218    }
219
220    /**
221     * Returns the list of supported formats.
222     * @return the list of supported formats
223     */
224    public Collection<String> getFormats() {
225        return Collections.unmodifiableList(formats);
226    }
227
228    /**
229     * Gets the preferred format for this imagery layer.
230     * @return The preferred format as mime type.
231     */
232    public String getPreferredFormat() {
233        if (formats.contains("image/png")) {
234            return "image/png";
235        } else if (formats.contains("image/jpeg")) {
236            return "image/jpeg";
237        } else if (formats.isEmpty()) {
238            return null;
239        } else {
240            return formats.get(0);
241        }
242    }
243
244    /**
245     * @return root URL of services in this GetCapabilities
246     */
247    public String buildRootUrl() {
248        if (getMapUrl == null && capabilitiesUrl == null) {
249            return null;
250        }
251        if (getMapUrl != null) {
252            return getMapUrl;
253        }
254
255        URL serviceUrl = capabilitiesUrl;
256        StringBuilder a = new StringBuilder(serviceUrl.getProtocol());
257        a.append("://").append(serviceUrl.getHost());
258        if (serviceUrl.getPort() != -1) {
259            a.append(':').append(serviceUrl.getPort());
260        }
261        a.append(serviceUrl.getPath()).append('?');
262        if (serviceUrl.getQuery() != null) {
263            a.append(serviceUrl.getQuery());
264            if (!serviceUrl.getQuery().isEmpty() && !serviceUrl.getQuery().endsWith("&")) {
265                a.append('&');
266            }
267        }
268        return a.toString();
269    }
270
271    /**
272     * Returns URL for accessing GetMap service. String will contain following parameters:
273     * * {proj} - that needs to be replaced with projection (one of {@link #getServerProjections(List)})
274     * * {width} - that needs to be replaced with width of the tile
275     * * {height} - that needs to be replaces with height of the tile
276     * * {bbox} - that needs to be replaced with area that should be fetched (in {proj} coordinates)
277     *
278     * Format of the response will be calculated using {@link #getPreferredFormat()}
279     *
280     * @param selectedLayers list of DefaultLayer selection of layers to be shown
281     * @param transparent whether returned images should contain transparent pixels (if supported by format)
282     * @return URL template for GetMap service containing
283     */
284    public String buildGetMapUrl(List<DefaultLayer> selectedLayers, boolean transparent) {
285        return buildGetMapUrl(
286                getLayers(selectedLayers),
287                selectedLayers.stream().map(DefaultLayer::getStyle).collect(Collectors.toList()),
288                transparent);
289    }
290
291    /**
292     * @param selectedLayers selected layers as subset of the tree returned by {@link #getLayers()}
293     * @param selectedStyles selected styles for all selectedLayers
294     * @param transparent whether returned images should contain transparent pixels (if supported by format)
295     * @return URL template for GetMap service
296     * @see #buildGetMapUrl(List, boolean)
297     */
298    public String buildGetMapUrl(List<LayerDetails> selectedLayers, List<String> selectedStyles, boolean transparent) {
299        return buildGetMapUrl(
300                selectedLayers.stream().map(LayerDetails::getName).collect(Collectors.toList()),
301                selectedStyles,
302                getPreferredFormat(),
303                transparent);
304    }
305
306    /**
307     * @param selectedLayers selected layers as list of strings
308     * @param selectedStyles selected styles of layers as list of strings
309     * @param format format of the response - one of {@link #getFormats()}
310     * @param transparent whether returned images should contain transparent pixels (if supported by format)
311     * @return URL template for GetMap service
312     * @see #buildGetMapUrl(List, boolean)
313     */
314    public String buildGetMapUrl(List<String> selectedLayers,
315            Collection<String> selectedStyles,
316            String format,
317            boolean transparent) {
318
319        Utils.ensure(selectedStyles == null || selectedLayers.size() == selectedStyles.size(),
320                tr("Styles size {0} does not match layers size {1}"),
321                selectedStyles == null ? 0 : selectedStyles.size(),
322                        selectedLayers.size());
323
324        return buildRootUrl() + "FORMAT=" + format + ((imageFormatHasTransparency(format) && transparent) ? "&TRANSPARENT=TRUE" : "")
325                + "&VERSION=" + this.version + "&SERVICE=WMS&REQUEST=GetMap&LAYERS="
326                + selectedLayers.stream().collect(Collectors.joining(","))
327                + "&STYLES="
328                + (selectedStyles != null ? Utils.join(",", selectedStyles) : "")
329                + "&"
330                + (belowWMS130() ? "SRS" : "CRS")
331                + "={proj}&WIDTH={width}&HEIGHT={height}&BBOX={bbox}";
332    }
333
334    private boolean tagEquals(QName a, QName b) {
335        boolean ret = a.equals(b);
336        if (ret) {
337            return ret;
338        }
339
340        if (belowWMS130()) {
341            return a.getLocalPart().equals(b.getLocalPart());
342        }
343
344        return false;
345    }
346
347    private void attemptGetCapabilities(String url) throws IOException, WMSGetCapabilitiesException {
348        Logging.debug("Trying WMS getcapabilities with url {0}", url);
349        try (CachedFile cf = new CachedFile(url); InputStream in = cf.setHttpHeaders(headers).
350                setMaxAge(7 * CachedFile.DAYS).
351                setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince).
352                getInputStream()) {
353
354            try {
355                XMLStreamReader reader = GetCapabilitiesParseHelper.getReader(in);
356                for (int event = reader.getEventType(); reader.hasNext(); event = reader.next()) {
357                    if (event == XMLStreamReader.START_ELEMENT) {
358                        if (tagEquals(CAPABILITIES_ROOT_111, reader.getName())) {
359                            // version 1.1.1
360                            this.version = reader.getAttributeValue(null, "version");
361                            if (this.version == null) {
362                                this.version = "1.1.1";
363                            }
364                        }
365                        if (tagEquals(CAPABILITITES_ROOT_130, reader.getName())) {
366                            this.version = reader.getAttributeValue(WMS_NS_URL, "version");
367                        }
368                        if (tagEquals(QN_SERVICE, reader.getName())) {
369                            parseService(reader);
370                        }
371
372                        if (tagEquals(QN_CAPABILITY, reader.getName())) {
373                            parseCapability(reader);
374                        }
375                    }
376                }
377            } catch (XMLStreamException e) {
378                String content = new String(cf.getByteContent(), UTF_8);
379                cf.clear(); // if there is a problem with parsing of the file, remove it from the cache
380                throw new WMSGetCapabilitiesException(e, content);
381            }
382        }
383    }
384
385    private void parseService(XMLStreamReader reader) throws XMLStreamException {
386        if (GetCapabilitiesParseHelper.moveReaderToTag(reader, this::tagEquals, QN_TITLE)) {
387            this.title = reader.getElementText();
388            // CHECKSTYLE.OFF: EmptyBlock
389            for (int event = reader.getEventType();
390                    reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_SERVICE, reader.getName()));
391                    event = reader.next()) {
392                // empty loop, just move reader to the end of Service tag, if moveReaderToTag return false, it's already done
393            }
394            // CHECKSTYLE.ON: EmptyBlock
395        }
396    }
397
398    private void parseCapability(XMLStreamReader reader) throws XMLStreamException {
399        for (int event = reader.getEventType();
400                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_CAPABILITY, reader.getName()));
401                event = reader.next()) {
402
403            if (event == XMLStreamReader.START_ELEMENT) {
404                if (tagEquals(QN_REQUEST, reader.getName())) {
405                    parseRequest(reader);
406                }
407                if (tagEquals(QN_LAYER, reader.getName())) {
408                    parseLayer(reader, null);
409                }
410            }
411        }
412    }
413
414    private void parseRequest(XMLStreamReader reader) throws XMLStreamException {
415        String mode = "";
416        String getMapUrl = "";
417        if (GetCapabilitiesParseHelper.moveReaderToTag(reader, this::tagEquals, QN_GETMAP)) {
418            for (int event = reader.getEventType();
419                    reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_GETMAP, reader.getName()));
420                    event = reader.next()) {
421
422                if (event == XMLStreamReader.START_ELEMENT) {
423                    if (tagEquals(QN_FORMAT, reader.getName())) {
424                        String value = reader.getElementText();
425                        if (isImageFormatSupportedWarn(value) && !this.formats.contains(value)) {
426                            this.formats.add(value);
427                        }
428                    }
429                    if (tagEquals(QN_DCPTYPE, reader.getName()) && GetCapabilitiesParseHelper.moveReaderToTag(reader,
430                            this::tagEquals, QN_HTTP, QN_GET)) {
431                        mode = reader.getName().getLocalPart();
432                        if (GetCapabilitiesParseHelper.moveReaderToTag(reader, this::tagEquals, QN_ONLINE_RESOURCE)) {
433                            getMapUrl = reader.getAttributeValue(GetCapabilitiesParseHelper.XLINK_NS_URL, "href");
434                        }
435                        // TODO should we handle also POST?
436                        if ("GET".equalsIgnoreCase(mode) && getMapUrl != null && !"".equals(getMapUrl)) {
437                            try {
438                                String query = (new URL(getMapUrl)).getQuery();
439                                if (query == null) {
440                                    this.getMapUrl = getMapUrl + "?";
441                                } else {
442                                    this.getMapUrl = getMapUrl;
443                                }
444                            } catch (MalformedURLException e) {
445                                throw new XMLStreamException(e);
446                            }
447                        }
448                    }
449                }
450            }
451        }
452    }
453
454    private void parseLayer(XMLStreamReader reader, LayerDetails parentLayer) throws XMLStreamException {
455        LayerDetails ret = new LayerDetails(parentLayer);
456        for (int event = reader.next(); // start with advancing reader by one element to get the contents of the layer
457                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_LAYER, reader.getName()));
458                event = reader.next()) {
459
460            if (event == XMLStreamReader.START_ELEMENT) {
461                if (tagEquals(QN_NAME, reader.getName())) {
462                    ret.setName(reader.getElementText());
463                } else if (tagEquals(QN_ABSTRACT, reader.getName())) {
464                    ret.setAbstract(GetCapabilitiesParseHelper.getElementTextWithSubtags(reader));
465                } else if (tagEquals(QN_TITLE, reader.getName())) {
466                    ret.setTitle(reader.getElementText());
467                } else if (tagEquals(QN_CRS, reader.getName())) {
468                    ret.addCrs(reader.getElementText());
469                } else if (tagEquals(QN_SRS, reader.getName()) && belowWMS130()) {
470                    ret.addCrs(reader.getElementText());
471                } else if (tagEquals(QN_STYLE, reader.getName())) {
472                    parseAndAddStyle(reader, ret);
473                } else if (tagEquals(QN_LAYER, reader.getName())) {
474                    parseLayer(reader, ret);
475                } else if (tagEquals(QN_EX_GEOGRAPHIC_BBOX, reader.getName()) && ret.getBounds() == null) {
476                    ret.setBounds(parseExGeographic(reader));
477                } else if (tagEquals(QN_BOUNDINGBOX, reader.getName())) {
478                    Projection conv;
479                    if (belowWMS130()) {
480                        conv = Projections.getProjectionByCode(reader.getAttributeValue(WMS_NS_URL, "SRS"));
481                    } else {
482                        conv = Projections.getProjectionByCode(reader.getAttributeValue(WMS_NS_URL, "CRS"));
483                    }
484                    if (ret.getBounds() == null && conv != null) {
485                        ret.setBounds(parseBoundingBox(reader, conv));
486                    }
487                } else if (tagEquals(QN_LATLONBOUNDINGBOX, reader.getName()) && belowWMS130() && ret.getBounds() == null) {
488                    ret.setBounds(parseBoundingBox(reader, null));
489                } else {
490                    // unknown tag, move to its end as it may have child elements
491                    GetCapabilitiesParseHelper.moveReaderToEndCurrentTag(reader);
492                }
493            }
494        }
495        this.layers.add(ret);
496    }
497
498    /**
499     * @return if this service operates at protocol level below 1.3.0
500     */
501    public boolean belowWMS130() {
502        return "1.1.1".equals(version) || "1.1".equals(version) || "1.0".equals(version);
503    }
504
505    private void parseAndAddStyle(XMLStreamReader reader, LayerDetails ld) throws XMLStreamException {
506        String name = null;
507        String title = null;
508        for (int event = reader.getEventType();
509                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_STYLE, reader.getName()));
510                event = reader.next()) {
511            if (event == XMLStreamReader.START_ELEMENT) {
512                if (tagEquals(QN_NAME, reader.getName())) {
513                    name = reader.getElementText();
514                }
515                if (tagEquals(QN_TITLE, reader.getName())) {
516                    title = reader.getElementText();
517                }
518            }
519        }
520        if (name == null) {
521            name = "";
522        }
523        ld.addStyle(name, title);
524    }
525
526    private Bounds parseExGeographic(XMLStreamReader reader) throws XMLStreamException {
527        String minx = null, maxx = null, maxy = null, miny = null;
528
529        for (int event = reader.getEventType();
530                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_EX_GEOGRAPHIC_BBOX, reader.getName()));
531                event = reader.next()) {
532            if (event == XMLStreamReader.START_ELEMENT) {
533                if (tagEquals(QN_WESTBOUNDLONGITUDE, reader.getName())) {
534                    minx = reader.getElementText();
535                }
536
537                if (tagEquals(QN_EASTBOUNDLONGITUDE, reader.getName())) {
538                    maxx = reader.getElementText();
539                }
540
541                if (tagEquals(QN_SOUTHBOUNDLATITUDE, reader.getName())) {
542                    miny = reader.getElementText();
543                }
544
545                if (tagEquals(QN_NORTHBOUNDLATITUDE, reader.getName())) {
546                    maxy = reader.getElementText();
547                }
548            }
549        }
550        return parseBBox(null, miny, minx, maxy, maxx);
551    }
552
553    private Bounds parseBoundingBox(XMLStreamReader reader, Projection conv) {
554        UnaryOperator<String> attrGetter = tag -> belowWMS130() ?
555                reader.getAttributeValue(null, tag)
556                : reader.getAttributeValue(WMS_NS_URL, tag);
557
558                return parseBBox(
559                        conv,
560                        attrGetter.apply("miny"),
561                        attrGetter.apply("minx"),
562                        attrGetter.apply("maxy"),
563                        attrGetter.apply("maxx")
564                        );
565    }
566
567    private static Bounds parseBBox(Projection conv, String miny, String minx, String maxy, String maxx) {
568        if (miny == null || minx == null || maxy == null || maxx == null) {
569            return null;
570        }
571        if (conv != null) {
572            return new Bounds(
573                    conv.eastNorth2latlon(new EastNorth(getDecimalDegree(minx), getDecimalDegree(miny))),
574                    conv.eastNorth2latlon(new EastNorth(getDecimalDegree(maxx), getDecimalDegree(maxy)))
575                    );
576        }
577        return new Bounds(
578                getDecimalDegree(miny),
579                getDecimalDegree(minx),
580                getDecimalDegree(maxy),
581                getDecimalDegree(maxx)
582                );
583    }
584
585    private static double getDecimalDegree(String value) {
586        // Some real-world WMS servers use a comma instead of a dot as decimal separator (seen in Polish WMS server)
587        return Double.parseDouble(value.replace(',', '.'));
588    }
589
590    private static String normalizeUrl(String serviceUrlStr) throws MalformedURLException {
591        URL getCapabilitiesUrl = null;
592        String ret = null;
593
594        if (!Pattern.compile(".*GetCapabilities.*", Pattern.CASE_INSENSITIVE).matcher(serviceUrlStr).matches()) {
595            // If the url doesn't already have GetCapabilities, add it in
596            getCapabilitiesUrl = new URL(serviceUrlStr);
597            if (getCapabilitiesUrl.getQuery() == null) {
598                ret = serviceUrlStr + '?' + CAPABILITIES_QUERY_STRING;
599            } else if (!getCapabilitiesUrl.getQuery().isEmpty() && !getCapabilitiesUrl.getQuery().endsWith("&")) {
600                ret = serviceUrlStr + '&' + CAPABILITIES_QUERY_STRING;
601            } else {
602                ret = serviceUrlStr + CAPABILITIES_QUERY_STRING;
603            }
604        } else {
605            // Otherwise assume it's a good URL and let the subsequent error
606            // handling systems deal with problems
607            ret = serviceUrlStr;
608        }
609        return ret;
610    }
611
612    private static boolean isImageFormatSupportedWarn(String format) {
613        boolean isFormatSupported = isImageFormatSupported(format);
614        if (!isFormatSupported) {
615            Logging.info("Skipping unsupported image format {0}", format);
616        }
617        return isFormatSupported;
618    }
619
620    static boolean isImageFormatSupported(final String format) {
621        return ImageIO.getImageReadersByMIMEType(format).hasNext()
622                // handles image/tiff image/tiff8 image/geotiff image/geotiff8
623                || isImageFormatSupported(format, "tiff", "geotiff")
624                || isImageFormatSupported(format, "png")
625                || isImageFormatSupported(format, "svg")
626                || isImageFormatSupported(format, "bmp");
627    }
628
629    static boolean isImageFormatSupported(String format, String... mimeFormats) {
630        for (String mime : mimeFormats) {
631            if (format.startsWith("image/" + mime)) {
632                return ImageIO.getImageReadersBySuffix(mimeFormats[0]).hasNext();
633            }
634        }
635        return false;
636    }
637
638    static boolean imageFormatHasTransparency(final String format) {
639        return format != null && (format.startsWith("image/png") || format.startsWith("image/gif")
640                || format.startsWith("image/svg") || format.startsWith("image/tiff"));
641    }
642
643    /**
644     * Creates ImageryInfo object from this GetCapabilities document
645     *
646     * @param name name of imagery layer
647     * @param selectedLayers layers which are to be used by this imagery layer
648     * @param selectedStyles styles that should be used for selectedLayers
649     * @param transparent if layer should be transparent
650     * @return ImageryInfo object
651     */
652    public ImageryInfo toImageryInfo(String name, List<LayerDetails> selectedLayers, List<String> selectedStyles, boolean transparent) {
653        ImageryInfo i = new ImageryInfo(name, buildGetMapUrl(selectedLayers, selectedStyles, transparent));
654        if (selectedLayers != null && !selectedLayers.isEmpty()) {
655            i.setServerProjections(getServerProjections(selectedLayers));
656        }
657        return i;
658    }
659
660    /**
661     * Returns projections that server supports for provided list of layers. This will be intersection of projections
662     * defined for each layer
663     *
664     * @param selectedLayers list of layers
665     * @return projection code
666     */
667    public Collection<String> getServerProjections(List<LayerDetails> selectedLayers) {
668        if (selectedLayers.isEmpty()) {
669            return Collections.emptyList();
670        }
671        Set<String> proj = new HashSet<>(selectedLayers.get(0).getCrs());
672
673        // set intersect with all layers
674        for (LayerDetails ld: selectedLayers) {
675            proj.retainAll(ld.getCrs());
676        }
677        return proj;
678    }
679
680    /**
681     * @param defaultLayers default layers that should select layer object
682     * @return collection of LayerDetails specified by DefaultLayers
683     */
684    public List<LayerDetails> getLayers(List<DefaultLayer> defaultLayers) {
685        Collection<String> layerNames = defaultLayers.stream().map(DefaultLayer::getLayerName).collect(Collectors.toList());
686        return layers.stream()
687                .flatMap(LayerDetails::flattened)
688                .filter(x -> layerNames.contains(x.getName()))
689                .collect(Collectors.toList());
690    }
691
692    /**
693     * @return title of this service
694     */
695    public String getTitle() {
696        return title;
697    }
698}