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