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 CAPABILITITES_ROOT_130 = new QName("WMS_Capabilities", WMS_NS_URL);
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     * @return root URL of services in this GetCapabilities
248     */
249    public String buildRootUrl() {
250        if (getMapUrl == null && capabilitiesUrl == null) {
251            return null;
252        }
253        if (getMapUrl != null) {
254            return getMapUrl;
255        }
256
257        URL serviceUrl = capabilitiesUrl;
258        StringBuilder a = new StringBuilder(serviceUrl.getProtocol());
259        a.append("://").append(serviceUrl.getHost());
260        if (serviceUrl.getPort() != -1) {
261            a.append(':').append(serviceUrl.getPort());
262        }
263        a.append(serviceUrl.getPath()).append('?');
264        if (serviceUrl.getQuery() != null) {
265            a.append(serviceUrl.getQuery());
266            if (!serviceUrl.getQuery().isEmpty() && !serviceUrl.getQuery().endsWith("&")) {
267                a.append('&');
268            }
269        }
270        return a.toString();
271    }
272
273    /**
274     * @return root URL of services without the GetCapabilities call
275     * @since 15209
276     */
277    public String buildRootUrlWithoutCapabilities() {
278        return buildRootUrl()
279                .replace(CAPABILITIES_QUERY_STRING, "")
280                .replace(SERVICE_WMS, "")
281                .replace(REQUEST_GET_CAPABILITIES, "")
282                .replace("?&", "?");
283    }
284
285    /**
286     * Returns URL for accessing GetMap service. String will contain following parameters:
287     * * {proj} - that needs to be replaced with projection (one of {@link #getServerProjections(List)})
288     * * {width} - that needs to be replaced with width of the tile
289     * * {height} - that needs to be replaces with height of the tile
290     * * {bbox} - that needs to be replaced with area that should be fetched (in {proj} coordinates)
291     *
292     * Format of the response will be calculated using {@link #getPreferredFormat()}
293     *
294     * @param selectedLayers list of DefaultLayer selection of layers to be shown
295     * @param transparent whether returned images should contain transparent pixels (if supported by format)
296     * @return URL template for GetMap service containing
297     */
298    public String buildGetMapUrl(List<DefaultLayer> selectedLayers, boolean transparent) {
299        return buildGetMapUrl(
300                getLayers(selectedLayers),
301                selectedLayers.stream().map(DefaultLayer::getStyle).collect(Collectors.toList()),
302                transparent);
303    }
304
305    /**
306     * @param selectedLayers selected layers as subset of the tree returned by {@link #getLayers()}
307     * @param selectedStyles selected styles for all selectedLayers
308     * @param transparent whether returned images should contain transparent pixels (if supported by format)
309     * @return URL template for GetMap service
310     * @see #buildGetMapUrl(List, boolean)
311     */
312    public String buildGetMapUrl(List<LayerDetails> selectedLayers, List<String> selectedStyles, boolean transparent) {
313        return buildGetMapUrl(selectedLayers, selectedStyles, getPreferredFormat(), transparent);
314    }
315
316    /**
317     * @param selectedLayers selected layers as subset of the tree returned by {@link #getLayers()}
318     * @param selectedStyles selected styles for all selectedLayers
319     * @param format format of the response - one of {@link #getFormats()}
320     * @param transparent whether returned images should contain transparent pixels (if supported by format)
321     * @return URL template for GetMap service
322     * @see #buildGetMapUrl(List, boolean)
323     * @since 15228
324     */
325    public String buildGetMapUrl(List<LayerDetails> selectedLayers, List<String> selectedStyles, String format, boolean transparent) {
326        return buildGetMapUrl(
327                selectedLayers.stream().map(LayerDetails::getName).collect(Collectors.toList()),
328                selectedStyles,
329                format,
330                transparent);
331    }
332
333    /**
334     * @param selectedLayers selected layers as list of strings
335     * @param selectedStyles selected styles of layers as list of strings
336     * @param format format of the response - one of {@link #getFormats()}
337     * @param transparent whether returned images should contain transparent pixels (if supported by format)
338     * @return URL template for GetMap service
339     * @see #buildGetMapUrl(List, boolean)
340     */
341    public String buildGetMapUrl(List<String> selectedLayers,
342            Collection<String> selectedStyles,
343            String format,
344            boolean transparent) {
345
346        Utils.ensure(selectedStyles == null || selectedLayers.size() == selectedStyles.size(),
347                tr("Styles size {0} does not match layers size {1}"),
348                selectedStyles == null ? 0 : selectedStyles.size(),
349                        selectedLayers.size());
350
351        return buildRootUrlWithoutCapabilities()
352                + "FORMAT=" + format + ((imageFormatHasTransparency(format) && transparent) ? "&TRANSPARENT=TRUE" : "")
353                + "&VERSION=" + this.version + "&" + SERVICE_WMS + "&REQUEST=GetMap&LAYERS="
354                + selectedLayers.stream().collect(Collectors.joining(","))
355                + "&STYLES="
356                + (selectedStyles != null ? Utils.join(",", selectedStyles) : "")
357                + "&"
358                + (belowWMS130() ? "SRS" : "CRS")
359                + "={proj}&WIDTH={width}&HEIGHT={height}&BBOX={bbox}";
360    }
361
362    private boolean tagEquals(QName a, QName b) {
363        boolean ret = a.equals(b);
364        if (ret) {
365            return ret;
366        }
367
368        if (belowWMS130()) {
369            return a.getLocalPart().equals(b.getLocalPart());
370        }
371
372        return false;
373    }
374
375    private void attemptGetCapabilities(String url) throws IOException, WMSGetCapabilitiesException {
376        Logging.debug("Trying WMS getcapabilities with url {0}", url);
377        try (CachedFile cf = new CachedFile(url); InputStream in = cf.setHttpHeaders(headers).
378                setMaxAge(7 * CachedFile.DAYS).
379                setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince).
380                getInputStream()) {
381
382            try {
383                XMLStreamReader reader = GetCapabilitiesParseHelper.getReader(in);
384                for (int event = reader.getEventType(); reader.hasNext(); event = reader.next()) {
385                    if (event == XMLStreamReader.START_ELEMENT) {
386                        if (tagEquals(CAPABILITIES_ROOT_111, reader.getName())) {
387                            // version 1.1.1
388                            this.version = reader.getAttributeValue(null, "version");
389                            if (this.version == null) {
390                                this.version = "1.1.1";
391                            }
392                        }
393                        if (tagEquals(CAPABILITITES_ROOT_130, reader.getName())) {
394                            this.version = reader.getAttributeValue(WMS_NS_URL, "version");
395                        }
396                        if (tagEquals(QN_SERVICE, reader.getName())) {
397                            parseService(reader);
398                        }
399
400                        if (tagEquals(QN_CAPABILITY, reader.getName())) {
401                            parseCapability(reader);
402                        }
403                    }
404                }
405            } catch (XMLStreamException e) {
406                String content = new String(cf.getByteContent(), UTF_8);
407                cf.clear(); // if there is a problem with parsing of the file, remove it from the cache
408                throw new WMSGetCapabilitiesException(e, content);
409            }
410        }
411    }
412
413    private void parseService(XMLStreamReader reader) throws XMLStreamException {
414        if (GetCapabilitiesParseHelper.moveReaderToTag(reader, this::tagEquals, QN_TITLE)) {
415            this.title = reader.getElementText();
416            // CHECKSTYLE.OFF: EmptyBlock
417            for (int event = reader.getEventType();
418                    reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_SERVICE, reader.getName()));
419                    event = reader.next()) {
420                // empty loop, just move reader to the end of Service tag, if moveReaderToTag return false, it's already done
421            }
422            // CHECKSTYLE.ON: EmptyBlock
423        }
424    }
425
426    private void parseCapability(XMLStreamReader reader) throws XMLStreamException {
427        for (int event = reader.getEventType();
428                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_CAPABILITY, reader.getName()));
429                event = reader.next()) {
430
431            if (event == XMLStreamReader.START_ELEMENT) {
432                if (tagEquals(QN_REQUEST, reader.getName())) {
433                    parseRequest(reader);
434                }
435                if (tagEquals(QN_LAYER, reader.getName())) {
436                    parseLayer(reader, null);
437                }
438            }
439        }
440    }
441
442    private void parseRequest(XMLStreamReader reader) throws XMLStreamException {
443        String mode = "";
444        String getMapUrl = "";
445        if (GetCapabilitiesParseHelper.moveReaderToTag(reader, this::tagEquals, QN_GETMAP)) {
446            for (int event = reader.getEventType();
447                    reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_GETMAP, reader.getName()));
448                    event = reader.next()) {
449
450                if (event == XMLStreamReader.START_ELEMENT) {
451                    if (tagEquals(QN_FORMAT, reader.getName())) {
452                        String value = reader.getElementText();
453                        if (isImageFormatSupportedWarn(value) && !this.formats.contains(value)) {
454                            this.formats.add(value);
455                        }
456                    }
457                    if (tagEquals(QN_DCPTYPE, reader.getName()) && GetCapabilitiesParseHelper.moveReaderToTag(reader,
458                            this::tagEquals, QN_HTTP, QN_GET)) {
459                        mode = reader.getName().getLocalPart();
460                        if (GetCapabilitiesParseHelper.moveReaderToTag(reader, this::tagEquals, QN_ONLINE_RESOURCE)) {
461                            getMapUrl = reader.getAttributeValue(GetCapabilitiesParseHelper.XLINK_NS_URL, "href");
462                        }
463                        // TODO should we handle also POST?
464                        if ("GET".equalsIgnoreCase(mode) && getMapUrl != null && !"".equals(getMapUrl)) {
465                            try {
466                                String query = (new URL(getMapUrl)).getQuery();
467                                if (query == null) {
468                                    this.getMapUrl = getMapUrl + "?";
469                                } else {
470                                    this.getMapUrl = getMapUrl;
471                                }
472                            } catch (MalformedURLException e) {
473                                throw new XMLStreamException(e);
474                            }
475                        }
476                    }
477                }
478            }
479        }
480    }
481
482    private void parseLayer(XMLStreamReader reader, LayerDetails parentLayer) throws XMLStreamException {
483        LayerDetails ret = new LayerDetails(parentLayer);
484        for (int event = reader.next(); // start with advancing reader by one element to get the contents of the layer
485                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_LAYER, reader.getName()));
486                event = reader.next()) {
487
488            if (event == XMLStreamReader.START_ELEMENT) {
489                if (tagEquals(QN_NAME, reader.getName())) {
490                    ret.setName(reader.getElementText());
491                } else if (tagEquals(QN_ABSTRACT, reader.getName())) {
492                    ret.setAbstract(GetCapabilitiesParseHelper.getElementTextWithSubtags(reader));
493                } else if (tagEquals(QN_TITLE, reader.getName())) {
494                    ret.setTitle(reader.getElementText());
495                } else if (tagEquals(QN_CRS, reader.getName())) {
496                    ret.addCrs(reader.getElementText());
497                } else if (tagEquals(QN_SRS, reader.getName()) && belowWMS130()) {
498                    ret.addCrs(reader.getElementText());
499                } else if (tagEquals(QN_STYLE, reader.getName())) {
500                    parseAndAddStyle(reader, ret);
501                } else if (tagEquals(QN_LAYER, reader.getName())) {
502                    parseLayer(reader, ret);
503                } else if (tagEquals(QN_EX_GEOGRAPHIC_BBOX, reader.getName()) && ret.getBounds() == null) {
504                    ret.setBounds(parseExGeographic(reader));
505                } else if (tagEquals(QN_BOUNDINGBOX, reader.getName())) {
506                    Projection conv;
507                    if (belowWMS130()) {
508                        conv = Projections.getProjectionByCode(reader.getAttributeValue(WMS_NS_URL, "SRS"));
509                    } else {
510                        conv = Projections.getProjectionByCode(reader.getAttributeValue(WMS_NS_URL, "CRS"));
511                    }
512                    if (ret.getBounds() == null && conv != null) {
513                        ret.setBounds(parseBoundingBox(reader, conv));
514                    }
515                } else if (tagEquals(QN_LATLONBOUNDINGBOX, reader.getName()) && belowWMS130() && ret.getBounds() == null) {
516                    ret.setBounds(parseBoundingBox(reader, null));
517                } else {
518                    // unknown tag, move to its end as it may have child elements
519                    GetCapabilitiesParseHelper.moveReaderToEndCurrentTag(reader);
520                }
521            }
522        }
523        this.layers.add(ret);
524    }
525
526    /**
527     * @return if this service operates at protocol level below 1.3.0
528     */
529    public boolean belowWMS130() {
530        return "1.1.1".equals(version) || "1.1".equals(version) || "1.0".equals(version);
531    }
532
533    private void parseAndAddStyle(XMLStreamReader reader, LayerDetails ld) throws XMLStreamException {
534        String name = null;
535        String title = null;
536        for (int event = reader.getEventType();
537                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_STYLE, reader.getName()));
538                event = reader.next()) {
539            if (event == XMLStreamReader.START_ELEMENT) {
540                if (tagEquals(QN_NAME, reader.getName())) {
541                    name = reader.getElementText();
542                }
543                if (tagEquals(QN_TITLE, reader.getName())) {
544                    title = reader.getElementText();
545                }
546            }
547        }
548        if (name == null) {
549            name = "";
550        }
551        ld.addStyle(name, title);
552    }
553
554    private Bounds parseExGeographic(XMLStreamReader reader) throws XMLStreamException {
555        String minx = null, maxx = null, maxy = null, miny = null;
556
557        for (int event = reader.getEventType();
558                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && tagEquals(QN_EX_GEOGRAPHIC_BBOX, reader.getName()));
559                event = reader.next()) {
560            if (event == XMLStreamReader.START_ELEMENT) {
561                if (tagEquals(QN_WESTBOUNDLONGITUDE, reader.getName())) {
562                    minx = reader.getElementText();
563                }
564
565                if (tagEquals(QN_EASTBOUNDLONGITUDE, reader.getName())) {
566                    maxx = reader.getElementText();
567                }
568
569                if (tagEquals(QN_SOUTHBOUNDLATITUDE, reader.getName())) {
570                    miny = reader.getElementText();
571                }
572
573                if (tagEquals(QN_NORTHBOUNDLATITUDE, reader.getName())) {
574                    maxy = reader.getElementText();
575                }
576            }
577        }
578        return parseBBox(null, miny, minx, maxy, maxx);
579    }
580
581    private Bounds parseBoundingBox(XMLStreamReader reader, Projection conv) {
582        UnaryOperator<String> attrGetter = tag -> belowWMS130() ?
583                reader.getAttributeValue(null, tag)
584                : reader.getAttributeValue(WMS_NS_URL, tag);
585
586                return parseBBox(
587                        conv,
588                        attrGetter.apply("miny"),
589                        attrGetter.apply("minx"),
590                        attrGetter.apply("maxy"),
591                        attrGetter.apply("maxx")
592                        );
593    }
594
595    private static Bounds parseBBox(Projection conv, String miny, String minx, String maxy, String maxx) {
596        if (miny == null || minx == null || maxy == null || maxx == null) {
597            return null;
598        }
599        if (conv != null) {
600            return new Bounds(
601                    conv.eastNorth2latlon(new EastNorth(getDecimalDegree(minx), getDecimalDegree(miny))),
602                    conv.eastNorth2latlon(new EastNorth(getDecimalDegree(maxx), getDecimalDegree(maxy)))
603                    );
604        }
605        return new Bounds(
606                getDecimalDegree(miny),
607                getDecimalDegree(minx),
608                getDecimalDegree(maxy),
609                getDecimalDegree(maxx)
610                );
611    }
612
613    private static double getDecimalDegree(String value) {
614        // Some real-world WMS servers use a comma instead of a dot as decimal separator (seen in Polish WMS server)
615        return Double.parseDouble(value.replace(',', '.'));
616    }
617
618    private static String normalizeUrl(String serviceUrlStr) throws MalformedURLException {
619        URL getCapabilitiesUrl = null;
620        String ret = null;
621
622        if (!Pattern.compile(".*GetCapabilities.*", Pattern.CASE_INSENSITIVE).matcher(serviceUrlStr).matches()) {
623            // If the url doesn't already have GetCapabilities, add it in
624            getCapabilitiesUrl = new URL(serviceUrlStr);
625            if (getCapabilitiesUrl.getQuery() == null) {
626                ret = serviceUrlStr + '?' + CAPABILITIES_QUERY_STRING;
627            } else if (!getCapabilitiesUrl.getQuery().isEmpty() && !getCapabilitiesUrl.getQuery().endsWith("&")) {
628                ret = serviceUrlStr + '&' + CAPABILITIES_QUERY_STRING;
629            } else {
630                ret = serviceUrlStr + CAPABILITIES_QUERY_STRING;
631            }
632        } else {
633            // Otherwise assume it's a good URL and let the subsequent error
634            // handling systems deal with problems
635            ret = serviceUrlStr;
636        }
637        return ret;
638    }
639
640    private static boolean isImageFormatSupportedWarn(String format) {
641        boolean isFormatSupported = isImageFormatSupported(format);
642        if (!isFormatSupported) {
643            Logging.info("Skipping unsupported image format {0}", format);
644        }
645        return isFormatSupported;
646    }
647
648    static boolean isImageFormatSupported(final String format) {
649        return ImageIO.getImageReadersByMIMEType(format).hasNext()
650                // handles image/tiff image/tiff8 image/geotiff image/geotiff8
651                || isImageFormatSupported(format, "tiff", "geotiff")
652                || isImageFormatSupported(format, "png")
653                || isImageFormatSupported(format, "svg")
654                || isImageFormatSupported(format, "bmp");
655    }
656
657    static boolean isImageFormatSupported(String format, String... mimeFormats) {
658        for (String mime : mimeFormats) {
659            if (format.startsWith("image/" + mime)) {
660                return ImageIO.getImageReadersBySuffix(mimeFormats[0]).hasNext();
661            }
662        }
663        return false;
664    }
665
666    static boolean imageFormatHasTransparency(final String format) {
667        return format != null && (format.startsWith("image/png") || format.startsWith("image/gif")
668                || format.startsWith("image/svg") || format.startsWith("image/tiff"));
669    }
670
671    /**
672     * Creates ImageryInfo object from this GetCapabilities document
673     *
674     * @param name name of imagery layer
675     * @param selectedLayers layers which are to be used by this imagery layer
676     * @param selectedStyles styles that should be used for selectedLayers
677     * @param format format of the response - one of {@link #getFormats()}
678     * @param transparent if layer should be transparent
679     * @return ImageryInfo object
680     * @since 15228
681     */
682    public ImageryInfo toImageryInfo(
683            String name, List<LayerDetails> selectedLayers, List<String> selectedStyles, String format, boolean transparent) {
684        ImageryInfo i = new ImageryInfo(name, buildGetMapUrl(selectedLayers, selectedStyles, format, transparent));
685        if (selectedLayers != null && !selectedLayers.isEmpty()) {
686            i.setServerProjections(getServerProjections(selectedLayers));
687        }
688        return i;
689    }
690
691    /**
692     * Returns projections that server supports for provided list of layers. This will be intersection of projections
693     * defined for each layer
694     *
695     * @param selectedLayers list of layers
696     * @return projection code
697     */
698    public Collection<String> getServerProjections(List<LayerDetails> selectedLayers) {
699        if (selectedLayers.isEmpty()) {
700            return Collections.emptyList();
701        }
702        Set<String> proj = new HashSet<>(selectedLayers.get(0).getCrs());
703
704        // set intersect with all layers
705        for (LayerDetails ld: selectedLayers) {
706            proj.retainAll(ld.getCrs());
707        }
708        return proj;
709    }
710
711    /**
712     * @param defaultLayers default layers that should select layer object
713     * @return collection of LayerDetails specified by DefaultLayers
714     */
715    public List<LayerDetails> getLayers(List<DefaultLayer> defaultLayers) {
716        Collection<String> layerNames = defaultLayers.stream().map(DefaultLayer::getLayerName).collect(Collectors.toList());
717        return layers.stream()
718                .flatMap(LayerDetails::flattened)
719                .filter(x -> layerNames.contains(x.getName()))
720                .collect(Collectors.toList());
721    }
722
723    /**
724     * @return title of this service
725     */
726    public String getTitle() {
727        return title;
728    }
729}