001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.io.imagery;
003
004import java.awt.HeadlessException;
005import java.io.IOException;
006import java.io.StringReader;
007import java.net.MalformedURLException;
008import java.net.URL;
009import java.util.ArrayList;
010import java.util.Collection;
011import java.util.Collections;
012import java.util.HashSet;
013import java.util.List;
014import java.util.Locale;
015import java.util.Set;
016import java.util.regex.Pattern;
017
018import javax.imageio.ImageIO;
019import javax.xml.parsers.DocumentBuilder;
020import javax.xml.parsers.ParserConfigurationException;
021
022import org.openstreetmap.josm.Main;
023import org.openstreetmap.josm.data.Bounds;
024import org.openstreetmap.josm.data.imagery.ImageryInfo;
025import org.openstreetmap.josm.data.projection.Projections;
026import org.openstreetmap.josm.tools.HttpClient;
027import org.openstreetmap.josm.tools.Predicate;
028import org.openstreetmap.josm.tools.Utils;
029import org.w3c.dom.Document;
030import org.w3c.dom.Element;
031import org.w3c.dom.Node;
032import org.w3c.dom.NodeList;
033import org.xml.sax.EntityResolver;
034import org.xml.sax.InputSource;
035import org.xml.sax.SAXException;
036
037public class WMSImagery {
038
039    public static class WMSGetCapabilitiesException extends Exception {
040        private final String incomingData;
041
042        /**
043         * Constructs a new {@code WMSGetCapabilitiesException}
044         * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method)
045         * @param incomingData the answer from WMS server
046         */
047        public WMSGetCapabilitiesException(Throwable cause, String incomingData) {
048            super(cause);
049            this.incomingData = incomingData;
050        }
051
052        /**
053         * Constructs a new {@code WMSGetCapabilitiesException}
054         * @param message   the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method
055         * @param incomingData the answer from the server
056         * @since 10520
057         */
058        public WMSGetCapabilitiesException(String message, String incomingData) {
059            super(message);
060            this.incomingData = incomingData;
061        }
062
063        /**
064         * Returns the answer from WMS server.
065         * @return the answer from WMS server
066         */
067        public String getIncomingData() {
068            return incomingData;
069        }
070    }
071
072    private List<LayerDetails> layers;
073    private URL serviceUrl;
074    private List<String> formats;
075
076    /**
077     * Returns the list of layers.
078     * @return the list of layers
079     */
080    public List<LayerDetails> getLayers() {
081        return layers;
082    }
083
084    /**
085     * Returns the service URL.
086     * @return the service URL
087     */
088    public URL getServiceUrl() {
089        return serviceUrl;
090    }
091
092    /**
093     * Returns the list of supported formats.
094     * @return the list of supported formats
095     */
096    public List<String> getFormats() {
097        return Collections.unmodifiableList(formats);
098    }
099
100    public String getPreferredFormats() {
101        return formats.contains("image/jpeg") ? "image/jpeg"
102                : formats.contains("image/png") ? "image/png"
103                : formats.isEmpty() ? null
104                : formats.get(0);
105    }
106
107    String buildRootUrl() {
108        if (serviceUrl == null) {
109            return null;
110        }
111        StringBuilder a = new StringBuilder(serviceUrl.getProtocol());
112        a.append("://").append(serviceUrl.getHost());
113        if (serviceUrl.getPort() != -1) {
114            a.append(':').append(serviceUrl.getPort());
115        }
116        a.append(serviceUrl.getPath()).append('?');
117        if (serviceUrl.getQuery() != null) {
118            a.append(serviceUrl.getQuery());
119            if (!serviceUrl.getQuery().isEmpty() && !serviceUrl.getQuery().endsWith("&")) {
120                a.append('&');
121            }
122        }
123        return a.toString();
124    }
125
126    public String buildGetMapUrl(Collection<LayerDetails> selectedLayers) {
127        return buildGetMapUrl(selectedLayers, "image/jpeg");
128    }
129
130    public String buildGetMapUrl(Collection<LayerDetails> selectedLayers, String format) {
131        return buildRootUrl()
132                + "FORMAT=" + format + (imageFormatHasTransparency(format) ? "&TRANSPARENT=TRUE" : "")
133                + "&VERSION=1.1.1&SERVICE=WMS&REQUEST=GetMap&LAYERS="
134                + Utils.join(",", Utils.transform(selectedLayers, new Utils.Function<LayerDetails, String>() {
135            @Override
136            public String apply(LayerDetails x) {
137                return x.ident;
138            }
139        }))
140                + "&STYLES=&SRS={proj}&WIDTH={width}&HEIGHT={height}&BBOX={bbox}";
141    }
142
143    public void attemptGetCapabilities(String serviceUrlStr) throws MalformedURLException, IOException, WMSGetCapabilitiesException {
144        URL getCapabilitiesUrl = null;
145        try {
146            if (!Pattern.compile(".*GetCapabilities.*", Pattern.CASE_INSENSITIVE).matcher(serviceUrlStr).matches()) {
147                // If the url doesn't already have GetCapabilities, add it in
148                getCapabilitiesUrl = new URL(serviceUrlStr);
149                final String getCapabilitiesQuery = "VERSION=1.1.1&SERVICE=WMS&REQUEST=GetCapabilities";
150                if (getCapabilitiesUrl.getQuery() == null) {
151                    getCapabilitiesUrl = new URL(serviceUrlStr + '?' + getCapabilitiesQuery);
152                } else if (!getCapabilitiesUrl.getQuery().isEmpty() && !getCapabilitiesUrl.getQuery().endsWith("&")) {
153                    getCapabilitiesUrl = new URL(serviceUrlStr + '&' + getCapabilitiesQuery);
154                } else {
155                    getCapabilitiesUrl = new URL(serviceUrlStr + getCapabilitiesQuery);
156                }
157            } else {
158                // Otherwise assume it's a good URL and let the subsequent error
159                // handling systems deal with problems
160                getCapabilitiesUrl = new URL(serviceUrlStr);
161            }
162            serviceUrl = new URL(serviceUrlStr);
163        } catch (HeadlessException e) {
164            return;
165        }
166
167        Main.info("GET " + getCapabilitiesUrl);
168        final String incomingData = HttpClient.create(getCapabilitiesUrl).connect().fetchContent();
169        Main.debug("Server response to Capabilities request:");
170        Main.debug(incomingData);
171
172        try {
173            DocumentBuilder builder = Utils.newSafeDOMBuilder();
174            builder.setEntityResolver(new EntityResolver() {
175                @Override
176                public InputSource resolveEntity(String publicId, String systemId) throws SAXException, IOException {
177                    Main.info("Ignoring DTD " + publicId + ", " + systemId);
178                    return new InputSource(new StringReader(""));
179                }
180            });
181            Document document = builder.parse(new InputSource(new StringReader(incomingData)));
182            Element root = document.getDocumentElement();
183
184            // Check if the request resulted in ServiceException
185            if ("ServiceException".equals(root.getTagName())) {
186                throw new WMSGetCapabilitiesException(root.getTextContent(), incomingData);
187            }
188
189            // Some WMS service URLs specify a different base URL for their GetMap service
190            Element child = getChild(root, "Capability");
191            child = getChild(child, "Request");
192            child = getChild(child, "GetMap");
193
194            formats = new ArrayList<>(Utils.filter(Utils.transform(getChildren(child, "Format"),
195                    new Utils.Function<Element, String>() {
196                        @Override
197                        public String apply(Element x) {
198                            return x.getTextContent();
199                        }
200                    }),
201                    new Predicate<String>() {
202                        @Override
203                        public boolean evaluate(String format) {
204                            boolean isFormatSupported = isImageFormatSupported(format);
205                            if (!isFormatSupported) {
206                                Main.info("Skipping unsupported image format {0}", format);
207                            }
208                            return isFormatSupported;
209                        }
210                    }
211            ));
212
213            child = getChild(child, "DCPType");
214            child = getChild(child, "HTTP");
215            child = getChild(child, "Get");
216            child = getChild(child, "OnlineResource");
217            if (child != null) {
218                String baseURL = child.getAttribute("xlink:href");
219                if (baseURL != null && !baseURL.equals(serviceUrlStr)) {
220                    Main.info("GetCapabilities specifies a different service URL: " + baseURL);
221                    serviceUrl = new URL(baseURL);
222                }
223            }
224
225            Element capabilityElem = getChild(root, "Capability");
226            List<Element> children = getChildren(capabilityElem, "Layer");
227            layers = parseLayers(children, new HashSet<String>());
228        } catch (MalformedURLException | ParserConfigurationException | SAXException e) {
229            throw new WMSGetCapabilitiesException(e, incomingData);
230        }
231    }
232
233    static boolean isImageFormatSupported(final String format) {
234        return ImageIO.getImageReadersByMIMEType(format).hasNext()
235                // handles image/tiff image/tiff8 image/geotiff image/geotiff8
236                || (format.startsWith("image/tiff") || format.startsWith("image/geotiff")) && ImageIO.getImageReadersBySuffix("tiff").hasNext()
237                || format.startsWith("image/png") && ImageIO.getImageReadersBySuffix("png").hasNext()
238                || format.startsWith("image/svg") && ImageIO.getImageReadersBySuffix("svg").hasNext()
239                || format.startsWith("image/bmp") && ImageIO.getImageReadersBySuffix("bmp").hasNext();
240    }
241
242    static boolean imageFormatHasTransparency(final String format) {
243        return format != null && (format.startsWith("image/png") || format.startsWith("image/gif")
244                || format.startsWith("image/svg") || format.startsWith("image/tiff"));
245    }
246
247    public ImageryInfo toImageryInfo(String name, Collection<LayerDetails> selectedLayers) {
248        ImageryInfo i = new ImageryInfo(name, buildGetMapUrl(selectedLayers));
249        if (selectedLayers != null) {
250            Set<String> proj = new HashSet<>();
251            for (WMSImagery.LayerDetails l : selectedLayers) {
252                proj.addAll(l.getProjections());
253            }
254            i.setServerProjections(proj);
255        }
256        return i;
257    }
258
259    private List<LayerDetails> parseLayers(List<Element> children, Set<String> parentCrs) {
260        List<LayerDetails> details = new ArrayList<>(children.size());
261        for (Element element : children) {
262            details.add(parseLayer(element, parentCrs));
263        }
264        return details;
265    }
266
267    private LayerDetails parseLayer(Element element, Set<String> parentCrs) {
268        String name = getChildContent(element, "Title", null, null);
269        String ident = getChildContent(element, "Name", null, null);
270
271        // The set of supported CRS/SRS for this layer
272        Set<String> crsList = new HashSet<>();
273        // ...including this layer's already-parsed parent projections
274        crsList.addAll(parentCrs);
275
276        // Parse the CRS/SRS pulled out of this layer's XML element
277        // I think CRS and SRS are the same at this point
278        List<Element> crsChildren = getChildren(element, "CRS");
279        crsChildren.addAll(getChildren(element, "SRS"));
280        for (Element child : crsChildren) {
281            String crs = (String) getContent(child);
282            if (!crs.isEmpty()) {
283                String upperCase = crs.trim().toUpperCase(Locale.ENGLISH);
284                crsList.add(upperCase);
285            }
286        }
287
288        // Check to see if any of the specified projections are supported by JOSM
289        boolean josmSupportsThisLayer = false;
290        for (String crs : crsList) {
291            josmSupportsThisLayer |= isProjSupported(crs);
292        }
293
294        Bounds bounds = null;
295        Element bboxElem = getChild(element, "EX_GeographicBoundingBox");
296        if (bboxElem != null) {
297            // Attempt to use EX_GeographicBoundingBox for bounding box
298            double left = Double.parseDouble(getChildContent(bboxElem, "westBoundLongitude", null, null));
299            double top = Double.parseDouble(getChildContent(bboxElem, "northBoundLatitude", null, null));
300            double right = Double.parseDouble(getChildContent(bboxElem, "eastBoundLongitude", null, null));
301            double bot = Double.parseDouble(getChildContent(bboxElem, "southBoundLatitude", null, null));
302            bounds = new Bounds(bot, left, top, right);
303        } else {
304            // If that's not available, try LatLonBoundingBox
305            bboxElem = getChild(element, "LatLonBoundingBox");
306            if (bboxElem != null) {
307                double left = Double.parseDouble(bboxElem.getAttribute("minx"));
308                double top = Double.parseDouble(bboxElem.getAttribute("maxy"));
309                double right = Double.parseDouble(bboxElem.getAttribute("maxx"));
310                double bot = Double.parseDouble(bboxElem.getAttribute("miny"));
311                bounds = new Bounds(bot, left, top, right);
312            }
313        }
314
315        List<Element> layerChildren = getChildren(element, "Layer");
316        List<LayerDetails> childLayers = parseLayers(layerChildren, crsList);
317
318        return new LayerDetails(name, ident, crsList, josmSupportsThisLayer, bounds, childLayers);
319    }
320
321    private static boolean isProjSupported(String crs) {
322        return Projections.getProjectionByCode(crs) != null;
323    }
324
325    private static String getChildContent(Element parent, String name, String missing, String empty) {
326        Element child = getChild(parent, name);
327        if (child == null)
328            return missing;
329        else {
330            String content = (String) getContent(child);
331            return (!content.isEmpty()) ? content : empty;
332        }
333    }
334
335    private static Object getContent(Element element) {
336        NodeList nl = element.getChildNodes();
337        StringBuilder content = new StringBuilder();
338        for (int i = 0; i < nl.getLength(); i++) {
339            Node node = nl.item(i);
340            switch (node.getNodeType()) {
341                case Node.ELEMENT_NODE:
342                    return node;
343                case Node.CDATA_SECTION_NODE:
344                case Node.TEXT_NODE:
345                    content.append(node.getNodeValue());
346                    break;
347                default: // Do nothing
348            }
349        }
350        return content.toString().trim();
351    }
352
353    private static List<Element> getChildren(Element parent, String name) {
354        List<Element> retVal = new ArrayList<>();
355        if (parent != null) {
356            for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) {
357                if (child instanceof Element && name.equals(child.getNodeName())) {
358                    retVal.add((Element) child);
359                }
360            }
361        }
362        return retVal;
363    }
364
365    private static Element getChild(Element parent, String name) {
366        if (parent == null)
367            return null;
368        for (Node child = parent.getFirstChild(); child != null; child = child.getNextSibling()) {
369            if (child instanceof Element && name.equals(child.getNodeName()))
370                return (Element) child;
371        }
372        return null;
373    }
374
375    public static class LayerDetails {
376
377        public final String name;
378        public final String ident;
379        public final List<LayerDetails> children;
380        public final Bounds bounds;
381        public final Set<String> crsList;
382        public final boolean supported;
383
384        public LayerDetails(String name, String ident, Set<String> crsList,
385                            boolean supportedLayer, Bounds bounds,
386                            List<LayerDetails> childLayers) {
387            this.name = name;
388            this.ident = ident;
389            this.supported = supportedLayer;
390            this.children = childLayers;
391            this.bounds = bounds;
392            this.crsList = crsList;
393        }
394
395        public boolean isSupported() {
396            return this.supported;
397        }
398
399        public Set<String> getProjections() {
400            return crsList;
401        }
402
403        @Override
404        public String toString() {
405            if (this.name == null || this.name.isEmpty())
406                return this.ident;
407            else
408                return this.name;
409        }
410    }
411}