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