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