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