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