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