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