001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.imagery; 003 004import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_DCP; 005import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_GET; 006import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_HTTP; 007import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_IDENTIFIER; 008import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_OPERATION; 009import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_OPERATIONS_METADATA; 010import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_SUPPORTED_CRS; 011import static org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.QN_OWS_TITLE; 012import static org.openstreetmap.josm.tools.I18n.tr; 013 014import java.awt.Point; 015import java.io.ByteArrayInputStream; 016import java.io.IOException; 017import java.io.InputStream; 018import java.nio.charset.StandardCharsets; 019import java.nio.file.InvalidPathException; 020import java.util.ArrayList; 021import java.util.Arrays; 022import java.util.Collection; 023import java.util.Collections; 024import java.util.Deque; 025import java.util.LinkedHashSet; 026import java.util.LinkedList; 027import java.util.List; 028import java.util.Map; 029import java.util.Map.Entry; 030import java.util.Objects; 031import java.util.Optional; 032import java.util.SortedSet; 033import java.util.TreeSet; 034import java.util.concurrent.ConcurrentHashMap; 035import java.util.regex.Matcher; 036import java.util.regex.Pattern; 037import java.util.stream.Collectors; 038 039import javax.imageio.ImageIO; 040import javax.xml.namespace.QName; 041import javax.xml.stream.XMLStreamException; 042import javax.xml.stream.XMLStreamReader; 043 044import org.openstreetmap.gui.jmapviewer.Coordinate; 045import org.openstreetmap.gui.jmapviewer.Projected; 046import org.openstreetmap.gui.jmapviewer.Tile; 047import org.openstreetmap.gui.jmapviewer.TileRange; 048import org.openstreetmap.gui.jmapviewer.TileXY; 049import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate; 050import org.openstreetmap.gui.jmapviewer.interfaces.IProjected; 051import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource; 052import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource; 053import org.openstreetmap.josm.data.ProjectionBounds; 054import org.openstreetmap.josm.data.coor.EastNorth; 055import org.openstreetmap.josm.data.coor.LatLon; 056import org.openstreetmap.josm.data.imagery.GetCapabilitiesParseHelper.TransferMode; 057import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType; 058import org.openstreetmap.josm.data.projection.Projection; 059import org.openstreetmap.josm.data.projection.ProjectionRegistry; 060import org.openstreetmap.josm.data.projection.Projections; 061import org.openstreetmap.josm.gui.ExtendedDialog; 062import org.openstreetmap.josm.gui.MainApplication; 063import org.openstreetmap.josm.gui.layer.NativeScaleLayer.ScaleList; 064import org.openstreetmap.josm.gui.layer.imagery.WMTSLayerSelection; 065import org.openstreetmap.josm.io.CachedFile; 066import org.openstreetmap.josm.spi.preferences.Config; 067import org.openstreetmap.josm.tools.CheckParameterUtil; 068import org.openstreetmap.josm.tools.Logging; 069import org.openstreetmap.josm.tools.Utils; 070 071/** 072 * Tile Source handling WMTS providers 073 * 074 * @author Wiktor Niesiobędzki 075 * @since 8526 076 */ 077public class WMTSTileSource extends AbstractTMSTileSource implements TemplatedTileSource { 078 /** 079 * WMTS namespace address 080 */ 081 public static final String WMTS_NS_URL = "http://www.opengis.net/wmts/1.0"; 082 083 // CHECKSTYLE.OFF: SingleSpaceSeparator 084 private static final QName QN_CONTENTS = new QName(WMTS_NS_URL, "Contents"); 085 private static final QName QN_DEFAULT = new QName(WMTS_NS_URL, "Default"); 086 private static final QName QN_DIMENSION = new QName(WMTS_NS_URL, "Dimension"); 087 private static final QName QN_FORMAT = new QName(WMTS_NS_URL, "Format"); 088 private static final QName QN_LAYER = new QName(WMTS_NS_URL, "Layer"); 089 private static final QName QN_MATRIX_WIDTH = new QName(WMTS_NS_URL, "MatrixWidth"); 090 private static final QName QN_MATRIX_HEIGHT = new QName(WMTS_NS_URL, "MatrixHeight"); 091 private static final QName QN_RESOURCE_URL = new QName(WMTS_NS_URL, "ResourceURL"); 092 private static final QName QN_SCALE_DENOMINATOR = new QName(WMTS_NS_URL, "ScaleDenominator"); 093 private static final QName QN_STYLE = new QName(WMTS_NS_URL, "Style"); 094 private static final QName QN_TILEMATRIX = new QName(WMTS_NS_URL, "TileMatrix"); 095 private static final QName QN_TILEMATRIXSET = new QName(WMTS_NS_URL, "TileMatrixSet"); 096 private static final QName QN_TILEMATRIX_SET_LINK = new QName(WMTS_NS_URL, "TileMatrixSetLink"); 097 private static final QName QN_TILE_WIDTH = new QName(WMTS_NS_URL, "TileWidth"); 098 private static final QName QN_TILE_HEIGHT = new QName(WMTS_NS_URL, "TileHeight"); 099 private static final QName QN_TOPLEFT_CORNER = new QName(WMTS_NS_URL, "TopLeftCorner"); 100 private static final QName QN_VALUE = new QName(WMTS_NS_URL, "Value"); 101 // CHECKSTYLE.ON: SingleSpaceSeparator 102 103 private static final String PATTERN_HEADER = "\\{header\\(([^,]+),([^}]+)\\)\\}"; 104 105 private static final String URL_GET_ENCODING_PARAMS = "SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER={layer}&STYLE={style}&" 106 + "FORMAT={format}&tileMatrixSet={TileMatrixSet}&tileMatrix={TileMatrix}&tileRow={TileRow}&tileCol={TileCol}"; 107 108 private static final String[] ALL_PATTERNS = { 109 PATTERN_HEADER, 110 }; 111 112 private int cachedTileSize = -1; 113 114 private static class TileMatrix { 115 private String identifier; 116 private double scaleDenominator; 117 private EastNorth topLeftCorner; 118 private int tileWidth; 119 private int tileHeight; 120 private int matrixWidth = -1; 121 private int matrixHeight = -1; 122 } 123 124 private static class TileMatrixSetBuilder { 125 // sorted by zoom level 126 SortedSet<TileMatrix> tileMatrix = new TreeSet<>((o1, o2) -> -1 * Double.compare(o1.scaleDenominator, o2.scaleDenominator)); 127 private String crs; 128 private String identifier; 129 130 TileMatrixSet build() { 131 return new TileMatrixSet(this); 132 } 133 } 134 135 /** 136 * 137 * class representing WMTS TileMatrixSet 138 * This connects projection and TileMatrix (how the map is divided in tiles) 139 * 140 */ 141 public static class TileMatrixSet { 142 143 private final List<TileMatrix> tileMatrix; 144 private final String crs; 145 private final String identifier; 146 147 TileMatrixSet(TileMatrixSet tileMatrixSet) { 148 if (tileMatrixSet != null) { 149 tileMatrix = new ArrayList<>(tileMatrixSet.tileMatrix); 150 crs = tileMatrixSet.crs; 151 identifier = tileMatrixSet.identifier; 152 } else { 153 tileMatrix = Collections.emptyList(); 154 crs = null; 155 identifier = null; 156 } 157 } 158 159 TileMatrixSet(TileMatrixSetBuilder builder) { 160 tileMatrix = new ArrayList<>(builder.tileMatrix); 161 crs = builder.crs; 162 identifier = builder.identifier; 163 } 164 165 @Override 166 public String toString() { 167 return "TileMatrixSet [crs=" + crs + ", identifier=" + identifier + ']'; 168 } 169 170 /** 171 * 172 * @return identifier of this TileMatrixSet 173 */ 174 public String getIdentifier() { 175 return identifier; 176 } 177 178 /** 179 * 180 * @return projection of this tileMatrix 181 */ 182 public String getCrs() { 183 return crs; 184 } 185 } 186 187 private static class Dimension { 188 private String identifier; 189 private String defaultValue; 190 private final List<String> values = new ArrayList<>(); 191 } 192 193 /** 194 * Class representing WMTS Layer information 195 * 196 */ 197 public static class Layer { 198 private String format; 199 private String identifier; 200 private String title; 201 private TileMatrixSet tileMatrixSet; 202 private String baseUrl; 203 private String style; 204 private final Collection<String> tileMatrixSetLinks = new ArrayList<>(); 205 private final Collection<Dimension> dimensions = new ArrayList<>(); 206 207 Layer(Layer l) { 208 Objects.requireNonNull(l); 209 format = l.format; 210 identifier = l.identifier; 211 title = l.title; 212 baseUrl = l.baseUrl; 213 style = l.style; 214 tileMatrixSet = new TileMatrixSet(l.tileMatrixSet); 215 dimensions.addAll(l.dimensions); 216 } 217 218 Layer() { 219 } 220 221 /** 222 * Get title of the layer for user display. 223 * 224 * This is either the content of the Title element (if available) or 225 * the layer identifier (as fallback) 226 * @return title of the layer for user display 227 */ 228 public String getUserTitle() { 229 return title != null ? title : identifier; 230 } 231 232 @Override 233 public String toString() { 234 return "Layer [identifier=" + identifier + ", title=" + title + ", tileMatrixSet=" 235 + tileMatrixSet + ", baseUrl=" + baseUrl + ", style=" + style + ']'; 236 } 237 238 /** 239 * 240 * @return identifier of this layer 241 */ 242 public String getIdentifier() { 243 return identifier; 244 } 245 246 /** 247 * 248 * @return style of this layer 249 */ 250 public String getStyle() { 251 return style; 252 } 253 254 /** 255 * 256 * @return tileMatrixSet of this layer 257 */ 258 public TileMatrixSet getTileMatrixSet() { 259 return tileMatrixSet; 260 } 261 } 262 263 /** 264 * Exception thrown when parser doesn't find expected information in GetCapabilities document 265 * 266 */ 267 public static class WMTSGetCapabilitiesException extends Exception { 268 269 /** 270 * Create WMTS exception 271 * @param cause description of cause 272 */ 273 public WMTSGetCapabilitiesException(String cause) { 274 super(cause); 275 } 276 277 /** 278 * Create WMTS exception 279 * @param cause description of cause 280 * @param t nested exception 281 */ 282 public WMTSGetCapabilitiesException(String cause, Throwable t) { 283 super(cause, t); 284 } 285 } 286 287 private static final class SelectLayerDialog extends ExtendedDialog { 288 private final WMTSLayerSelection list; 289 290 SelectLayerDialog(Collection<Layer> layers) { 291 super(MainApplication.getMainFrame(), tr("Select WMTS layer"), tr("Add layers"), tr("Cancel")); 292 this.list = new WMTSLayerSelection(groupLayersByNameAndTileMatrixSet(layers)); 293 setContent(list); 294 } 295 296 public DefaultLayer getSelectedLayer() { 297 Layer selectedLayer = list.getSelectedLayer(); 298 return new DefaultLayer(ImageryType.WMTS, selectedLayer.identifier, selectedLayer.style, selectedLayer.tileMatrixSet.identifier); 299 } 300 301 } 302 303 private final Map<String, String> headers = new ConcurrentHashMap<>(); 304 private final Collection<Layer> layers; 305 private Layer currentLayer; 306 private TileMatrixSet currentTileMatrixSet; 307 private double crsScale; 308 private final TransferMode transferMode; 309 310 private ScaleList nativeScaleList; 311 312 private final DefaultLayer defaultLayer; 313 314 private Projection tileProjection; 315 316 /** 317 * Creates a tile source based on imagery info 318 * @param info imagery info 319 * @throws IOException if any I/O error occurs 320 * @throws WMTSGetCapabilitiesException when document didn't contain any layers 321 * @throws IllegalArgumentException if any other error happens for the given imagery info 322 */ 323 public WMTSTileSource(ImageryInfo info) throws IOException, WMTSGetCapabilitiesException { 324 super(info); 325 CheckParameterUtil.ensureThat(info.getDefaultLayers().size() < 2, "At most 1 default layer for WMTS is supported"); 326 this.headers.putAll(info.getCustomHttpHeaders()); 327 this.baseUrl = GetCapabilitiesParseHelper.normalizeCapabilitiesUrl(handleTemplate(info.getUrl())); 328 WMTSCapabilities capabilities = getCapabilities(baseUrl, headers); 329 this.layers = capabilities.getLayers(); 330 this.baseUrl = capabilities.getBaseUrl(); 331 this.transferMode = capabilities.getTransferMode(); 332 if (info.getDefaultLayers().isEmpty()) { 333 Logging.warn(tr("No default layer selected, choosing first layer.")); 334 if (!layers.isEmpty()) { 335 Layer first = layers.iterator().next(); 336 this.defaultLayer = new DefaultLayer(info.getImageryType(), first.identifier, first.style, first.tileMatrixSet.identifier); 337 } else { 338 this.defaultLayer = null; 339 } 340 } else { 341 this.defaultLayer = info.getDefaultLayers().get(0); 342 } 343 if (this.layers.isEmpty()) 344 throw new IllegalArgumentException(tr("No layers defined by getCapabilities document: {0}", info.getUrl())); 345 } 346 347 /** 348 * Creates a tile source based on imagery info and initializes it with given projection. 349 * @param info imagery info 350 * @param projection projection to be used by this TileSource 351 * @throws IOException if any I/O error occurs 352 * @throws WMTSGetCapabilitiesException when document didn't contain any layers 353 * @throws IllegalArgumentException if any other error happens for the given imagery info 354 * @since 14507 355 */ 356 public WMTSTileSource(ImageryInfo info, Projection projection) throws IOException, WMTSGetCapabilitiesException { 357 this(info); 358 initProjection(projection); 359 } 360 361 /** 362 * Creates a dialog based on this tile source with all available layers and returns the name of selected layer 363 * @return Name of selected layer 364 */ 365 public DefaultLayer userSelectLayer() { 366 Map<String, List<Layer>> layerById = layers.stream().collect( 367 Collectors.groupingBy(x -> x.identifier)); 368 if (layerById.size() == 1) { // only one layer 369 List<Layer> ls = layerById.entrySet().iterator().next().getValue() 370 .stream().filter( 371 u -> u.tileMatrixSet.crs.equals(ProjectionRegistry.getProjection().toCode())) 372 .collect(Collectors.toList()); 373 if (ls.size() == 1) { 374 // only one tile matrix set with matching projection - no point in asking 375 Layer selectedLayer = ls.get(0); 376 return new DefaultLayer(ImageryType.WMTS, selectedLayer.identifier, selectedLayer.style, selectedLayer.tileMatrixSet.identifier); 377 } 378 } 379 380 final SelectLayerDialog layerSelection = new SelectLayerDialog(layers); 381 if (layerSelection.showDialog().getValue() == 1) { 382 return layerSelection.getSelectedLayer(); 383 } 384 return null; 385 } 386 387 private String handleTemplate(String url) { 388 Pattern pattern = Pattern.compile(PATTERN_HEADER); 389 StringBuffer output = new StringBuffer(); 390 Matcher matcher = pattern.matcher(url); 391 while (matcher.find()) { 392 this.headers.put(matcher.group(1), matcher.group(2)); 393 matcher.appendReplacement(output, ""); 394 } 395 matcher.appendTail(output); 396 return output.toString(); 397 } 398 399 400 /** 401 * Call remote server and parse response to WMTSCapabilities object 402 * 403 * @param url of the getCapabilities document 404 * @param headers HTTP headers to set when calling getCapabilities url 405 * @return capabilities 406 * @throws IOException in case of any I/O error 407 * @throws WMTSGetCapabilitiesException when document didn't contain any layers 408 * @throws IllegalArgumentException in case of any other error 409 */ 410 public static WMTSCapabilities getCapabilities(String url, Map<String, String> headers) throws IOException, WMTSGetCapabilitiesException { 411 try (CachedFile cf = new CachedFile(url); InputStream in = cf.setHttpHeaders(headers). 412 setMaxAge(Config.getPref().getLong("wmts.capabilities.cache.max_age", 7 * CachedFile.DAYS)). 413 setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince). 414 getInputStream()) { 415 byte[] data = Utils.readBytesFromStream(in); 416 if (data.length == 0) { 417 cf.clear(); 418 throw new IllegalArgumentException("Could not read data from: " + url); 419 } 420 421 try { 422 XMLStreamReader reader = GetCapabilitiesParseHelper.getReader(new ByteArrayInputStream(data)); 423 WMTSCapabilities ret = null; 424 Collection<Layer> layers = null; 425 for (int event = reader.getEventType(); reader.hasNext(); event = reader.next()) { 426 if (event == XMLStreamReader.START_ELEMENT) { 427 QName qName = reader.getName(); 428 if (QN_OWS_OPERATIONS_METADATA.equals(qName)) { 429 ret = parseOperationMetadata(reader); 430 } else if (QN_CONTENTS.equals(qName)) { 431 layers = parseContents(reader); 432 } 433 } 434 } 435 if (ret == null) { 436 /* 437 * see #12168 - create dummy operation metadata - not all WMTS services provide this information 438 * 439 * WMTS Standard: 440 * > Resource oriented architecture style HTTP encodings SHALL not be described in the OperationsMetadata section. 441 * 442 * And OperationMetada is not mandatory element. So REST mode is justifiable 443 */ 444 ret = new WMTSCapabilities(url, TransferMode.REST); 445 } 446 if (layers == null) { 447 throw new WMTSGetCapabilitiesException(tr("WMTS Capabilities document did not contain layers in url: {0}", url)); 448 } 449 ret.addLayers(layers); 450 return ret; 451 } catch (XMLStreamException e) { 452 cf.clear(); 453 Logging.warn(new String(data, StandardCharsets.UTF_8)); 454 throw new WMTSGetCapabilitiesException(tr("Error during parsing of WMTS Capabilities document: {0}", e.getMessage()), e); 455 } 456 } catch (InvalidPathException e) { 457 throw new WMTSGetCapabilitiesException(tr("Invalid path for GetCapabilities document: {0}", e.getMessage()), e); 458 } 459 } 460 461 /** 462 * Parse Contents tag. Returns when reader reaches Contents closing tag 463 * 464 * @param reader StAX reader instance 465 * @return collection of layers within contents with properly linked TileMatrixSets 466 * @throws XMLStreamException See {@link XMLStreamReader} 467 */ 468 private static Collection<Layer> parseContents(XMLStreamReader reader) throws XMLStreamException { 469 Map<String, TileMatrixSet> matrixSetById = new ConcurrentHashMap<>(); 470 Collection<Layer> layers = new ArrayList<>(); 471 for (int event = reader.getEventType(); 472 reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_CONTENTS.equals(reader.getName())); 473 event = reader.next()) { 474 if (event == XMLStreamReader.START_ELEMENT) { 475 QName qName = reader.getName(); 476 if (QN_LAYER.equals(qName)) { 477 Layer l = parseLayer(reader); 478 if (l != null) { 479 layers.add(l); 480 } 481 } else if (QN_TILEMATRIXSET.equals(qName)) { 482 TileMatrixSet entry = parseTileMatrixSet(reader); 483 matrixSetById.put(entry.identifier, entry); 484 } 485 } 486 } 487 Collection<Layer> ret = new ArrayList<>(); 488 // link layers to matrix sets 489 for (Layer l: layers) { 490 for (String tileMatrixId: l.tileMatrixSetLinks) { 491 Layer newLayer = new Layer(l); // create a new layer object for each tile matrix set supported 492 newLayer.tileMatrixSet = matrixSetById.get(tileMatrixId); 493 ret.add(newLayer); 494 } 495 } 496 return ret; 497 } 498 499 /** 500 * Parse Layer tag. Returns when reader will reach Layer closing tag 501 * 502 * @param reader StAX reader instance 503 * @return Layer object, with tileMatrixSetLinks and no tileMatrixSet attribute set. 504 * @throws XMLStreamException See {@link XMLStreamReader} 505 */ 506 private static Layer parseLayer(XMLStreamReader reader) throws XMLStreamException { 507 Layer layer = new Layer(); 508 Deque<QName> tagStack = new LinkedList<>(); 509 List<String> supportedMimeTypes = new ArrayList<>(Arrays.asList(ImageIO.getReaderMIMETypes())); 510 supportedMimeTypes.add("image/jpgpng"); // used by ESRI 511 supportedMimeTypes.add("image/png8"); // used by geoserver 512 if (supportedMimeTypes.contains("image/jpeg")) { 513 supportedMimeTypes.add("image/jpg"); // sometimes misspelled by Arcgis 514 } 515 Collection<String> unsupportedFormats = new ArrayList<>(); 516 517 for (int event = reader.getEventType(); 518 reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_LAYER.equals(reader.getName())); 519 event = reader.next()) { 520 if (event == XMLStreamReader.START_ELEMENT) { 521 QName qName = reader.getName(); 522 tagStack.push(qName); 523 if (tagStack.size() == 2) { 524 if (QN_FORMAT.equals(qName)) { 525 String format = reader.getElementText(); 526 if (supportedMimeTypes.contains(format)) { 527 layer.format = format; 528 } else { 529 unsupportedFormats.add(format); 530 } 531 } else if (QN_OWS_IDENTIFIER.equals(qName)) { 532 layer.identifier = reader.getElementText(); 533 } else if (QN_OWS_TITLE.equals(qName)) { 534 layer.title = reader.getElementText(); 535 } else if (QN_RESOURCE_URL.equals(qName) && 536 "tile".equals(reader.getAttributeValue("", "resourceType"))) { 537 layer.baseUrl = reader.getAttributeValue("", "template"); 538 } else if (QN_STYLE.equals(qName) && 539 "true".equals(reader.getAttributeValue("", "isDefault"))) { 540 if (GetCapabilitiesParseHelper.moveReaderToTag(reader, QN_OWS_IDENTIFIER)) { 541 layer.style = reader.getElementText(); 542 tagStack.push(reader.getName()); // keep tagStack in sync 543 } 544 } else if (QN_DIMENSION.equals(qName)) { 545 layer.dimensions.add(parseDimension(reader)); 546 } else if (QN_TILEMATRIX_SET_LINK.equals(qName)) { 547 layer.tileMatrixSetLinks.add(parseTileMatrixSetLink(reader)); 548 } else { 549 GetCapabilitiesParseHelper.moveReaderToEndCurrentTag(reader); 550 } 551 } 552 } 553 // need to get event type from reader, as parsing might have change position of reader 554 if (reader.getEventType() == XMLStreamReader.END_ELEMENT) { 555 QName start = tagStack.pop(); 556 if (!start.equals(reader.getName())) { 557 throw new IllegalStateException(tr("WMTS Parser error - start element {0} has different name than end element {2}", 558 start, reader.getName())); 559 } 560 } 561 } 562 if (layer.style == null) { 563 layer.style = ""; 564 } 565 if (layer.format == null) { 566 // no format found - it's mandatory parameter - can't use this layer 567 Logging.warn(tr("Can''t use layer {0} because no supported formats where found. Layer is available in formats: {1}", 568 layer.getUserTitle(), 569 String.join(", ", unsupportedFormats))); 570 return null; 571 } 572 return layer; 573 } 574 575 /** 576 * Gets Dimension value. Returns when reader is on Dimension closing tag 577 * 578 * @param reader StAX reader instance 579 * @return dimension 580 * @throws XMLStreamException See {@link XMLStreamReader} 581 */ 582 private static Dimension parseDimension(XMLStreamReader reader) throws XMLStreamException { 583 Dimension ret = new Dimension(); 584 for (int event = reader.getEventType(); 585 reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && 586 QN_DIMENSION.equals(reader.getName())); 587 event = reader.next()) { 588 if (event == XMLStreamReader.START_ELEMENT) { 589 QName qName = reader.getName(); 590 if (QN_OWS_IDENTIFIER.equals(qName)) { 591 ret.identifier = reader.getElementText(); 592 } else if (QN_DEFAULT.equals(qName)) { 593 ret.defaultValue = reader.getElementText(); 594 } else if (QN_VALUE.equals(qName)) { 595 ret.values.add(reader.getElementText()); 596 } 597 } 598 } 599 return ret; 600 } 601 602 /** 603 * Gets TileMatrixSetLink value. Returns when reader is on TileMatrixSetLink closing tag 604 * 605 * @param reader StAX reader instance 606 * @return TileMatrixSetLink identifier 607 * @throws XMLStreamException See {@link XMLStreamReader} 608 */ 609 private static String parseTileMatrixSetLink(XMLStreamReader reader) throws XMLStreamException { 610 String ret = null; 611 for (int event = reader.getEventType(); 612 reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && 613 QN_TILEMATRIX_SET_LINK.equals(reader.getName())); 614 event = reader.next()) { 615 if (event == XMLStreamReader.START_ELEMENT && QN_TILEMATRIXSET.equals(reader.getName())) { 616 ret = reader.getElementText(); 617 } 618 } 619 return ret; 620 } 621 622 /** 623 * Parses TileMatrixSet section. Returns when reader is on TileMatrixSet closing tag 624 * @param reader StAX reader instance 625 * @return TileMatrixSet object 626 * @throws XMLStreamException See {@link XMLStreamReader} 627 */ 628 private static TileMatrixSet parseTileMatrixSet(XMLStreamReader reader) throws XMLStreamException { 629 TileMatrixSetBuilder matrixSet = new TileMatrixSetBuilder(); 630 for (int event = reader.getEventType(); 631 reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_TILEMATRIXSET.equals(reader.getName())); 632 event = reader.next()) { 633 if (event == XMLStreamReader.START_ELEMENT) { 634 QName qName = reader.getName(); 635 if (QN_OWS_IDENTIFIER.equals(qName)) { 636 matrixSet.identifier = reader.getElementText(); 637 } else if (QN_OWS_SUPPORTED_CRS.equals(qName)) { 638 matrixSet.crs = GetCapabilitiesParseHelper.crsToCode(reader.getElementText()); 639 } else if (QN_TILEMATRIX.equals(qName)) { 640 matrixSet.tileMatrix.add(parseTileMatrix(reader, matrixSet.crs)); 641 } 642 } 643 } 644 return matrixSet.build(); 645 } 646 647 /** 648 * Parses TileMatrix section. Returns when reader is on TileMatrix closing tag. 649 * @param reader StAX reader instance 650 * @param matrixCrs projection used by this matrix 651 * @return TileMatrix object 652 * @throws XMLStreamException See {@link XMLStreamReader} 653 */ 654 private static TileMatrix parseTileMatrix(XMLStreamReader reader, String matrixCrs) throws XMLStreamException { 655 Projection matrixProj = Optional.ofNullable(Projections.getProjectionByCode(matrixCrs)) 656 .orElseGet(ProjectionRegistry::getProjection); // use current projection if none found. Maybe user is using custom string 657 TileMatrix ret = new TileMatrix(); 658 for (int event = reader.getEventType(); 659 reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && QN_TILEMATRIX.equals(reader.getName())); 660 event = reader.next()) { 661 if (event == XMLStreamReader.START_ELEMENT) { 662 QName qName = reader.getName(); 663 if (QN_OWS_IDENTIFIER.equals(qName)) { 664 ret.identifier = reader.getElementText(); 665 } else if (QN_SCALE_DENOMINATOR.equals(qName)) { 666 ret.scaleDenominator = Double.parseDouble(reader.getElementText()); 667 } else if (QN_TOPLEFT_CORNER.equals(qName)) { 668 String[] topLeftCorner = reader.getElementText().split(" "); 669 if (matrixProj.switchXY()) { 670 ret.topLeftCorner = new EastNorth(Double.parseDouble(topLeftCorner[1]), Double.parseDouble(topLeftCorner[0])); 671 } else { 672 ret.topLeftCorner = new EastNorth(Double.parseDouble(topLeftCorner[0]), Double.parseDouble(topLeftCorner[1])); 673 } 674 } else if (QN_TILE_HEIGHT.equals(qName)) { 675 ret.tileHeight = Integer.parseInt(reader.getElementText()); 676 } else if (QN_TILE_WIDTH.equals(qName)) { 677 ret.tileWidth = Integer.parseInt(reader.getElementText()); 678 } else if (QN_MATRIX_HEIGHT.equals(qName)) { 679 ret.matrixHeight = Integer.parseInt(reader.getElementText()); 680 } else if (QN_MATRIX_WIDTH.equals(qName)) { 681 ret.matrixWidth = Integer.parseInt(reader.getElementText()); 682 } 683 } 684 } 685 if (ret.tileHeight != ret.tileWidth) { 686 throw new AssertionError(tr("Only square tiles are supported. {0}x{1} returned by server for TileMatrix identifier {2}", 687 ret.tileHeight, ret.tileWidth, ret.identifier)); 688 } 689 return ret; 690 } 691 692 /** 693 * Parses OperationMetadata section. Returns when reader is on OperationsMetadata closing tag. 694 * return WMTSCapabilities with baseUrl and transferMode 695 * 696 * @param reader StAX reader instance 697 * @return WMTSCapabilities with baseUrl and transferMode set 698 * @throws XMLStreamException See {@link XMLStreamReader} 699 */ 700 private static WMTSCapabilities parseOperationMetadata(XMLStreamReader reader) throws XMLStreamException { 701 for (int event = reader.getEventType(); 702 reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && 703 QN_OWS_OPERATIONS_METADATA.equals(reader.getName())); 704 event = reader.next()) { 705 if (event == XMLStreamReader.START_ELEMENT && 706 QN_OWS_OPERATION.equals(reader.getName()) && 707 "GetTile".equals(reader.getAttributeValue("", "name")) && 708 GetCapabilitiesParseHelper.moveReaderToTag(reader, QN_OWS_DCP, QN_OWS_HTTP, QN_OWS_GET)) { 709 return new WMTSCapabilities( 710 reader.getAttributeValue(GetCapabilitiesParseHelper.XLINK_NS_URL, "href"), 711 GetCapabilitiesParseHelper.getTransferMode(reader) 712 ); 713 } 714 } 715 return null; 716 } 717 718 /** 719 * Initializes projection for this TileSource with projection 720 * @param proj projection to be used by this TileSource 721 */ 722 public void initProjection(Projection proj) { 723 if (proj.equals(tileProjection)) 724 return; 725 List<Layer> matchingLayers = layers.stream().filter( 726 l -> l.identifier.equals(defaultLayer.getLayerName()) && l.tileMatrixSet.crs.equals(proj.toCode())) 727 .collect(Collectors.toList()); 728 if (matchingLayers.size() > 1) { 729 this.currentLayer = matchingLayers.stream().filter( 730 l -> l.tileMatrixSet.identifier.equals(defaultLayer.getTileMatrixSet())) 731 .findFirst().orElse(matchingLayers.get(0)); 732 this.tileProjection = proj; 733 } else if (matchingLayers.size() == 1) { 734 this.currentLayer = matchingLayers.get(0); 735 this.tileProjection = proj; 736 } else { 737 // no tile matrix sets with current projection 738 if (this.currentLayer == null) { 739 this.tileProjection = null; 740 for (Layer layer : layers) { 741 if (!layer.identifier.equals(defaultLayer.getLayerName())) { 742 continue; 743 } 744 Projection pr = Projections.getProjectionByCode(layer.tileMatrixSet.crs); 745 if (pr != null) { 746 this.currentLayer = layer; 747 this.tileProjection = pr; 748 break; 749 } 750 } 751 if (this.currentLayer == null) 752 throw new IllegalArgumentException( 753 layers.stream().map(l -> l.tileMatrixSet).collect(Collectors.toList()).toString()); 754 } // else: keep currentLayer and tileProjection as is 755 } 756 if (this.currentLayer != null) { 757 this.currentTileMatrixSet = this.currentLayer.tileMatrixSet; 758 Collection<Double> scales = new ArrayList<>(currentTileMatrixSet.tileMatrix.size()); 759 for (TileMatrix tileMatrix : currentTileMatrixSet.tileMatrix) { 760 scales.add(tileMatrix.scaleDenominator * 0.28e-03); 761 } 762 this.nativeScaleList = new ScaleList(scales); 763 } 764 this.crsScale = getTileSize() * 0.28e-03 / this.tileProjection.getMetersPerUnit(); 765 } 766 767 @Override 768 public int getTileSize() { 769 if (cachedTileSize > 0) { 770 return cachedTileSize; 771 } 772 if (currentTileMatrixSet != null) { 773 // no support for non-square tiles (tileHeight != tileWidth) 774 // and for different tile sizes at different zoom levels 775 cachedTileSize = currentTileMatrixSet.tileMatrix.get(0).tileHeight; 776 return cachedTileSize; 777 } 778 // Fallback to default mercator tile size. Maybe it will work 779 Logging.warn("WMTS: Could not determine tile size. Using default tile size of: {0}", getDefaultTileSize()); 780 return getDefaultTileSize(); 781 } 782 783 @Override 784 public String getTileUrl(int zoom, int tilex, int tiley) { 785 if (currentLayer == null) { 786 return ""; 787 } 788 789 String url; 790 if (currentLayer.baseUrl != null && transferMode == null) { 791 url = currentLayer.baseUrl; 792 } else { 793 switch (transferMode) { 794 case KVP: 795 url = baseUrl + URL_GET_ENCODING_PARAMS; 796 break; 797 case REST: 798 url = currentLayer.baseUrl; 799 break; 800 default: 801 url = ""; 802 break; 803 } 804 } 805 806 TileMatrix tileMatrix = getTileMatrix(zoom); 807 808 if (tileMatrix == null) { 809 return ""; // no matrix, probably unsupported CRS selected. 810 } 811 812 url = url.replaceAll("\\{layer\\}", this.currentLayer.identifier) 813 .replaceAll("\\{format\\}", this.currentLayer.format) 814 .replaceAll("\\{TileMatrixSet\\}", this.currentTileMatrixSet.identifier) 815 .replaceAll("\\{TileMatrix\\}", tileMatrix.identifier) 816 .replaceAll("\\{TileRow\\}", Integer.toString(tiley)) 817 .replaceAll("\\{TileCol\\}", Integer.toString(tilex)) 818 .replaceAll("(?i)\\{style\\}", this.currentLayer.style); 819 820 for (Dimension d : currentLayer.dimensions) { 821 url = url.replaceAll("(?i)\\{"+d.identifier+"\\}", d.defaultValue); 822 } 823 824 return url; 825 } 826 827 /** 828 * 829 * @param zoom zoom level 830 * @return TileMatrix that's working on this zoom level 831 */ 832 private TileMatrix getTileMatrix(int zoom) { 833 if (zoom > getMaxZoom()) { 834 return null; 835 } 836 if (zoom < 0) { 837 return null; 838 } 839 return this.currentTileMatrixSet.tileMatrix.get(zoom); 840 } 841 842 @Override 843 public double getDistance(double lat1, double lon1, double lat2, double lon2) { 844 throw new UnsupportedOperationException("Not implemented"); 845 } 846 847 @Override 848 public ICoordinate tileXYToLatLon(Tile tile) { 849 return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom()); 850 } 851 852 @Override 853 public ICoordinate tileXYToLatLon(TileXY xy, int zoom) { 854 return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom); 855 } 856 857 @Override 858 public ICoordinate tileXYToLatLon(int x, int y, int zoom) { 859 TileMatrix matrix = getTileMatrix(zoom); 860 if (matrix == null) { 861 return CoordinateConversion.llToCoor(tileProjection.getWorldBoundsLatLon().getCenter()); 862 } 863 double scale = matrix.scaleDenominator * this.crsScale; 864 EastNorth ret = new EastNorth(matrix.topLeftCorner.east() + x * scale, matrix.topLeftCorner.north() - y * scale); 865 return CoordinateConversion.llToCoor(tileProjection.eastNorth2latlon(ret)); 866 } 867 868 @Override 869 public TileXY latLonToTileXY(double lat, double lon, int zoom) { 870 TileMatrix matrix = getTileMatrix(zoom); 871 if (matrix == null) { 872 return new TileXY(0, 0); 873 } 874 875 EastNorth enPoint = tileProjection.latlon2eastNorth(new LatLon(lat, lon)); 876 double scale = matrix.scaleDenominator * this.crsScale; 877 return new TileXY( 878 (enPoint.east() - matrix.topLeftCorner.east()) / scale, 879 (matrix.topLeftCorner.north() - enPoint.north()) / scale 880 ); 881 } 882 883 @Override 884 public TileXY latLonToTileXY(ICoordinate point, int zoom) { 885 return latLonToTileXY(point.getLat(), point.getLon(), zoom); 886 } 887 888 @Override 889 public int getTileXMax(int zoom) { 890 return getTileXMax(zoom, tileProjection); 891 } 892 893 @Override 894 public int getTileYMax(int zoom) { 895 return getTileYMax(zoom, tileProjection); 896 } 897 898 @Override 899 public Point latLonToXY(double lat, double lon, int zoom) { 900 TileMatrix matrix = getTileMatrix(zoom); 901 if (matrix == null) { 902 return new Point(0, 0); 903 } 904 double scale = matrix.scaleDenominator * this.crsScale; 905 EastNorth point = tileProjection.latlon2eastNorth(new LatLon(lat, lon)); 906 return new Point( 907 (int) Math.round((point.east() - matrix.topLeftCorner.east()) / scale), 908 (int) Math.round((matrix.topLeftCorner.north() - point.north()) / scale) 909 ); 910 } 911 912 @Override 913 public Point latLonToXY(ICoordinate point, int zoom) { 914 return latLonToXY(point.getLat(), point.getLon(), zoom); 915 } 916 917 @Override 918 public Coordinate xyToLatLon(Point point, int zoom) { 919 return xyToLatLon(point.x, point.y, zoom); 920 } 921 922 @Override 923 public Coordinate xyToLatLon(int x, int y, int zoom) { 924 TileMatrix matrix = getTileMatrix(zoom); 925 if (matrix == null) { 926 return new Coordinate(0, 0); 927 } 928 double scale = matrix.scaleDenominator * this.crsScale; 929 EastNorth ret = new EastNorth( 930 matrix.topLeftCorner.east() + x * scale, 931 matrix.topLeftCorner.north() - y * scale 932 ); 933 LatLon ll = tileProjection.eastNorth2latlon(ret); 934 return new Coordinate(ll.lat(), ll.lon()); 935 } 936 937 @Override 938 public Map<String, String> getHeaders() { 939 return headers; 940 } 941 942 @Override 943 public int getMaxZoom() { 944 if (this.currentTileMatrixSet != null) { 945 return this.currentTileMatrixSet.tileMatrix.size()-1; 946 } 947 return 0; 948 } 949 950 @Override 951 public String getTileId(int zoom, int tilex, int tiley) { 952 return getTileUrl(zoom, tilex, tiley); 953 } 954 955 /** 956 * Checks if url is acceptable by this Tile Source 957 * @param url URL to check 958 */ 959 public static void checkUrl(String url) { 960 CheckParameterUtil.ensureParameterNotNull(url, "url"); 961 Matcher m = Pattern.compile("\\{[^}]*\\}").matcher(url); 962 while (m.find()) { 963 boolean isSupportedPattern = false; 964 for (String pattern : ALL_PATTERNS) { 965 if (m.group().matches(pattern)) { 966 isSupportedPattern = true; 967 break; 968 } 969 } 970 if (!isSupportedPattern) { 971 throw new IllegalArgumentException( 972 tr("{0} is not a valid WMS argument. Please check this server URL:\n{1}", m.group(), url)); 973 } 974 } 975 } 976 977 /** 978 * @param layers to be grouped 979 * @return list with entries - grouping identifier + list of layers 980 */ 981 public static List<Entry<String, List<Layer>>> groupLayersByNameAndTileMatrixSet(Collection<Layer> layers) { 982 Map<String, List<Layer>> layerByName = layers.stream().collect( 983 Collectors.groupingBy(x -> x.identifier + '\u001c' + x.tileMatrixSet.identifier)); 984 return layerByName.entrySet().stream().sorted(Map.Entry.comparingByKey()).collect(Collectors.toList()); 985 } 986 987 988 /** 989 * @return set of projection codes that this TileSource supports 990 */ 991 public Collection<String> getSupportedProjections() { 992 Collection<String> ret = new LinkedHashSet<>(); 993 if (currentLayer == null) { 994 for (Layer layer: this.layers) { 995 ret.add(layer.tileMatrixSet.crs); 996 } 997 } else { 998 for (Layer layer: this.layers) { 999 if (currentLayer.identifier.equals(layer.identifier)) { 1000 ret.add(layer.tileMatrixSet.crs); 1001 } 1002 } 1003 } 1004 return ret; 1005 } 1006 1007 private int getTileYMax(int zoom, Projection proj) { 1008 TileMatrix matrix = getTileMatrix(zoom); 1009 if (matrix == null) { 1010 return 0; 1011 } 1012 1013 if (matrix.matrixHeight != -1) { 1014 return matrix.matrixHeight; 1015 } 1016 1017 double scale = matrix.scaleDenominator * this.crsScale; 1018 EastNorth min = matrix.topLeftCorner; 1019 EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax()); 1020 return (int) Math.ceil(Math.abs(max.north() - min.north()) / scale); 1021 } 1022 1023 private int getTileXMax(int zoom, Projection proj) { 1024 TileMatrix matrix = getTileMatrix(zoom); 1025 if (matrix == null) { 1026 return 0; 1027 } 1028 if (matrix.matrixWidth != -1) { 1029 return matrix.matrixWidth; 1030 } 1031 1032 double scale = matrix.scaleDenominator * this.crsScale; 1033 EastNorth min = matrix.topLeftCorner; 1034 EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax()); 1035 return (int) Math.ceil(Math.abs(max.east() - min.east()) / scale); 1036 } 1037 1038 /** 1039 * Get native scales of tile source. 1040 * @return {@link ScaleList} of native scales 1041 */ 1042 public ScaleList getNativeScales() { 1043 return nativeScaleList; 1044 } 1045 1046 /** 1047 * Returns the tile projection. 1048 * @return the tile projection 1049 */ 1050 public Projection getTileProjection() { 1051 return tileProjection; 1052 } 1053 1054 @Override 1055 public IProjected tileXYtoProjected(int x, int y, int zoom) { 1056 TileMatrix matrix = getTileMatrix(zoom); 1057 if (matrix == null) { 1058 return new Projected(0, 0); 1059 } 1060 double scale = matrix.scaleDenominator * this.crsScale; 1061 return new Projected( 1062 matrix.topLeftCorner.east() + x * scale, 1063 matrix.topLeftCorner.north() - y * scale); 1064 } 1065 1066 @Override 1067 public TileXY projectedToTileXY(IProjected projected, int zoom) { 1068 TileMatrix matrix = getTileMatrix(zoom); 1069 if (matrix == null) { 1070 return new TileXY(0, 0); 1071 } 1072 double scale = matrix.scaleDenominator * this.crsScale; 1073 return new TileXY( 1074 (projected.getEast() - matrix.topLeftCorner.east()) / scale, 1075 -(projected.getNorth() - matrix.topLeftCorner.north()) / scale); 1076 } 1077 1078 private EastNorth tileToEastNorth(int x, int y, int z) { 1079 return CoordinateConversion.projToEn(this.tileXYtoProjected(x, y, z)); 1080 } 1081 1082 private ProjectionBounds getTileProjectionBounds(Tile tile) { 1083 ProjectionBounds pb = new ProjectionBounds(tileToEastNorth(tile.getXtile(), tile.getYtile(), tile.getZoom())); 1084 pb.extend(tileToEastNorth(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom())); 1085 return pb; 1086 } 1087 1088 @Override 1089 public boolean isInside(Tile inner, Tile outer) { 1090 ProjectionBounds pbInner = getTileProjectionBounds(inner); 1091 ProjectionBounds pbOuter = getTileProjectionBounds(outer); 1092 // a little tolerance, for when inner tile touches the border of the outer tile 1093 double epsilon = 1e-7 * (pbOuter.maxEast - pbOuter.minEast); 1094 return pbOuter.minEast <= pbInner.minEast + epsilon && 1095 pbOuter.minNorth <= pbInner.minNorth + epsilon && 1096 pbOuter.maxEast >= pbInner.maxEast - epsilon && 1097 pbOuter.maxNorth >= pbInner.maxNorth - epsilon; 1098 } 1099 1100 @Override 1101 public TileRange getCoveringTileRange(Tile tile, int newZoom) { 1102 TileMatrix matrixNew = getTileMatrix(newZoom); 1103 if (matrixNew == null) { 1104 return new TileRange(new TileXY(0, 0), new TileXY(0, 0), newZoom); 1105 } 1106 IProjected p0 = tileXYtoProjected(tile.getXtile(), tile.getYtile(), tile.getZoom()); 1107 IProjected p1 = tileXYtoProjected(tile.getXtile() + 1, tile.getYtile() + 1, tile.getZoom()); 1108 TileXY tMin = projectedToTileXY(p0, newZoom); 1109 TileXY tMax = projectedToTileXY(p1, newZoom); 1110 // shrink the target tile a little, so we don't get neighboring tiles, that 1111 // share an edge, but don't actually cover the target tile 1112 double epsilon = 1e-7 * (tMax.getX() - tMin.getX()); 1113 int minX = (int) Math.floor(tMin.getX() + epsilon); 1114 int minY = (int) Math.floor(tMin.getY() + epsilon); 1115 int maxX = (int) Math.ceil(tMax.getX() - epsilon) - 1; 1116 int maxY = (int) Math.ceil(tMax.getY() - epsilon) - 1; 1117 return new TileRange(new TileXY(minX, minY), new TileXY(maxX, maxY), newZoom); 1118 } 1119 1120 @Override 1121 public String getServerCRS() { 1122 return tileProjection != null ? tileProjection.toCode() : null; 1123 } 1124 1125 /** 1126 * Layers that can be used with this tile source 1127 * @return unmodifiable collection of layers available in this tile source 1128 * @since 13879 1129 */ 1130 public Collection<Layer> getLayers() { 1131 return Collections.unmodifiableCollection(layers); 1132 } 1133}