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.net.MalformedURLException;
012import java.net.URL;
013import java.util.ArrayList;
014import java.util.Collection;
015import java.util.Collections;
016import java.util.Comparator;
017import java.util.HashSet;
018import java.util.List;
019import java.util.Map;
020import java.util.Set;
021import java.util.SortedSet;
022import java.util.Stack;
023import java.util.TreeSet;
024import java.util.concurrent.ConcurrentHashMap;
025import java.util.regex.Matcher;
026import java.util.regex.Pattern;
027
028import javax.swing.JPanel;
029import javax.swing.JScrollPane;
030import javax.swing.JTable;
031import javax.swing.ListSelectionModel;
032import javax.swing.table.AbstractTableModel;
033import javax.xml.namespace.QName;
034import javax.xml.stream.XMLInputFactory;
035import javax.xml.stream.XMLStreamException;
036import javax.xml.stream.XMLStreamReader;
037
038import org.openstreetmap.gui.jmapviewer.Coordinate;
039import org.openstreetmap.gui.jmapviewer.Tile;
040import org.openstreetmap.gui.jmapviewer.TileXY;
041import org.openstreetmap.gui.jmapviewer.interfaces.ICoordinate;
042import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource;
043import org.openstreetmap.gui.jmapviewer.tilesources.AbstractTMSTileSource;
044import org.openstreetmap.josm.Main;
045import org.openstreetmap.josm.data.coor.EastNorth;
046import org.openstreetmap.josm.data.coor.LatLon;
047import org.openstreetmap.josm.data.projection.Projection;
048import org.openstreetmap.josm.data.projection.Projections;
049import org.openstreetmap.josm.gui.ExtendedDialog;
050import org.openstreetmap.josm.gui.layer.NativeScaleLayer.ScaleList;
051import org.openstreetmap.josm.io.CachedFile;
052import org.openstreetmap.josm.tools.CheckParameterUtil;
053import org.openstreetmap.josm.tools.GBC;
054import org.openstreetmap.josm.tools.Utils;
055
056/**
057 * Tile Source handling WMS providers
058 *
059 * @author Wiktor Niesiobędzki
060 * @since 8526
061 */
062public class WMTSTileSource extends AbstractTMSTileSource implements TemplatedTileSource {
063    private static final String PATTERN_HEADER  = "\\{header\\(([^,]+),([^}]+)\\)\\}";
064
065    private static final String URL_GET_ENCODING_PARAMS = "SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER={layer}&STYLE={style}&"
066            + "FORMAT={format}&tileMatrixSet={TileMatrixSet}&tileMatrix={TileMatrix}&tileRow={TileRow}&tileCol={TileCol}";
067
068    private static final String[] ALL_PATTERNS = {
069        PATTERN_HEADER,
070    };
071
072    private static final String OWS_NS_URL = "http://www.opengis.net/ows/1.1";
073    private static final String WMTS_NS_URL = "http://www.opengis.net/wmts/1.0";
074    private static final String XLINK_NS_URL = "http://www.w3.org/1999/xlink";
075
076    private static class TileMatrix {
077        private String identifier;
078        private double scaleDenominator;
079        private EastNorth topLeftCorner;
080        private int tileWidth;
081        private int tileHeight;
082        private int matrixWidth = -1;
083        private int matrixHeight = -1;
084    }
085
086    private static class TileMatrixSetBuilder {
087        SortedSet<TileMatrix> tileMatrix = new TreeSet<>(new Comparator<TileMatrix>() {
088            @Override
089            public int compare(TileMatrix o1, TileMatrix o2) {
090                // reverse the order, so it will be from greatest (lowest zoom level) to lowest value (highest zoom level)
091                return -1 * Double.compare(o1.scaleDenominator, o2.scaleDenominator);
092            }
093        }); // sorted by zoom level
094        private String crs;
095        private String identifier;
096
097        TileMatrixSet build() {
098            return new TileMatrixSet(this);
099        }
100    }
101
102    private static class TileMatrixSet {
103
104        private final List<TileMatrix> tileMatrix;
105        private final String crs;
106        private final String identifier;
107
108        TileMatrixSet(TileMatrixSet tileMatrixSet) {
109            if (tileMatrixSet != null) {
110                tileMatrix = new ArrayList<>(tileMatrixSet.tileMatrix);
111                crs = tileMatrixSet.crs;
112                identifier = tileMatrixSet.identifier;
113            } else {
114                tileMatrix = Collections.emptyList();
115                crs = null;
116                identifier = null;
117            }
118        }
119
120        TileMatrixSet(TileMatrixSetBuilder builder) {
121            tileMatrix = new ArrayList<>(builder.tileMatrix);
122            crs = builder.crs;
123            identifier = builder.identifier;
124        }
125
126    }
127
128    private static class Layer {
129        private String format;
130        private String name;
131        private TileMatrixSet tileMatrixSet;
132        private String baseUrl;
133        private String style;
134        public Collection<String> tileMatrixSetLinks = new ArrayList<>();
135
136        Layer(Layer l) {
137            if (l != null) {
138                format = l.format;
139                name = l.name;
140                baseUrl = l.baseUrl;
141                style = l.style;
142                tileMatrixSet = new TileMatrixSet(l.tileMatrixSet);
143            }
144        }
145
146        Layer() {
147        }
148    }
149
150    private enum TransferMode {
151        KVP("KVP"),
152        REST("RESTful");
153
154        private final String typeString;
155
156        TransferMode(String urlString) {
157            this.typeString = urlString;
158        }
159
160        private String getTypeString() {
161            return typeString;
162        }
163
164        private static TransferMode fromString(String s) {
165            for (TransferMode type : TransferMode.values()) {
166                if (type.getTypeString().equals(s)) {
167                    return type;
168                }
169            }
170            return null;
171        }
172    }
173
174    private static final class SelectLayerDialog extends ExtendedDialog {
175        private final transient Layer[] layers;
176        private final JTable list;
177
178        SelectLayerDialog(Collection<Layer> layers) {
179            super(Main.parent, tr("Select WMTS layer"), new String[]{tr("Add layers"), tr("Cancel")});
180            this.layers = layers.toArray(new Layer[]{});
181            //getLayersTable(layers, Main.getProjection())
182            this.list = new JTable(
183                    new AbstractTableModel() {
184                        @Override
185                        public Object getValueAt(int rowIndex, int columnIndex) {
186                            switch (columnIndex) {
187                            case 0:
188                                return SelectLayerDialog.this.layers[rowIndex].name;
189                            case 1:
190                                return SelectLayerDialog.this.layers[rowIndex].tileMatrixSet.crs;
191                            case 2:
192                                return SelectLayerDialog.this.layers[rowIndex].tileMatrixSet.identifier;
193                            default:
194                                throw new IllegalArgumentException();
195                            }
196                        }
197
198                        @Override
199                        public int getRowCount() {
200                            return SelectLayerDialog.this.layers.length;
201                        }
202
203                        @Override
204                        public int getColumnCount() {
205                            return 3;
206                        }
207
208                        @Override
209                        public String getColumnName(int column) {
210                            switch (column) {
211                            case 0: return tr("Layer name");
212                            case 1: return tr("Projection");
213                            case 2: return tr("Matrix set identifier");
214                            default:
215                                throw new IllegalArgumentException();
216                            }
217                        }
218
219                        @Override
220                        public boolean isCellEditable(int row, int column) {
221                            return false;
222                        }
223                    });
224            this.list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
225            this.list.setRowSelectionAllowed(true);
226            this.list.setColumnSelectionAllowed(false);
227            JPanel panel = new JPanel(new GridBagLayout());
228            panel.add(new JScrollPane(this.list), GBC.eol().fill());
229            setContent(panel);
230        }
231
232        public Layer getSelectedLayer() {
233            int index = list.getSelectedRow();
234            if (index < 0) {
235                return null; //nothing selected
236            }
237            return layers[index];
238        }
239    }
240
241    private final Map<String, String> headers = new ConcurrentHashMap<>();
242    private Collection<Layer> layers;
243    private Layer currentLayer;
244    private TileMatrixSet currentTileMatrixSet;
245    private double crsScale;
246    private TransferMode transferMode;
247
248    private ScaleList nativeScaleList;
249
250    /**
251     * Creates a tile source based on imagery info
252     * @param info imagery info
253     * @throws IOException if any I/O error occurs
254     * @throws IllegalArgumentException if any other error happens for the given imagery info
255     */
256    public WMTSTileSource(ImageryInfo info) throws IOException {
257        super(info);
258        this.baseUrl = normalizeCapabilitiesUrl(handleTemplate(info.getUrl()));
259        this.layers = getCapabilities();
260        if (this.layers.isEmpty())
261            throw new IllegalArgumentException(tr("No layers defined by getCapabilities document: {0}", info.getUrl()));
262    }
263
264    private static Layer userSelectLayer(Collection<Layer> layers) {
265        if (layers.size() == 1)
266            return layers.iterator().next();
267        Layer ret = null;
268
269        final SelectLayerDialog layerSelection = new SelectLayerDialog(layers);
270        if (layerSelection.showDialog().getValue() == 1) {
271            ret = layerSelection.getSelectedLayer();
272            // TODO: save layer information into ImageryInfo / ImageryPreferences?
273        }
274        if (ret == null) {
275            // user canceled operation or did not choose any layer
276            throw new IllegalArgumentException(tr("No layer selected"));
277        }
278        return ret;
279    }
280
281    private String handleTemplate(String url) {
282        Pattern pattern = Pattern.compile(PATTERN_HEADER);
283        StringBuffer output = new StringBuffer(); // NOSONAR
284        Matcher matcher = pattern.matcher(url);
285        while (matcher.find()) {
286            this.headers.put(matcher.group(1), matcher.group(2));
287            matcher.appendReplacement(output, "");
288        }
289        matcher.appendTail(output);
290        return output.toString();
291    }
292
293    /**
294     * @return capabilities
295     * @throws IOException in case of any I/O error
296     * @throws IllegalArgumentException in case of any other error
297     */
298    private Collection<Layer> getCapabilities() throws IOException {
299        XMLInputFactory factory = XMLInputFactory.newFactory();
300        // do not try to load external entities, nor validate the XML
301        factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, Boolean.FALSE);
302        factory.setProperty(XMLInputFactory.IS_VALIDATING, Boolean.FALSE);
303        factory.setProperty(XMLInputFactory.SUPPORT_DTD, Boolean.FALSE);
304
305        try (CachedFile cf = new CachedFile(baseUrl); InputStream in = cf.setHttpHeaders(headers).
306                setMaxAge(7 * CachedFile.DAYS).
307                setCachingStrategy(CachedFile.CachingStrategy.IfModifiedSince).
308                getInputStream()) {
309            byte[] data = Utils.readBytesFromStream(in);
310            if (data == null || data.length == 0) {
311                throw new IllegalArgumentException("Could not read data from: " + baseUrl);
312            }
313            XMLStreamReader reader = factory.createXMLStreamReader(new ByteArrayInputStream(data));
314
315            Collection<Layer> ret = new ArrayList<>();
316            for (int event = reader.getEventType(); reader.hasNext(); event = reader.next()) {
317                if (event == XMLStreamReader.START_ELEMENT) {
318                    if (new QName(OWS_NS_URL, "OperationsMetadata").equals(reader.getName())) {
319                        parseOperationMetadata(reader);
320                    }
321
322                    if (new QName(WMTS_NS_URL, "Contents").equals(reader.getName())) {
323                        ret = parseContents(reader);
324                    }
325                }
326            }
327            return ret;
328        } catch (XMLStreamException e) {
329            throw new IllegalArgumentException(e);
330        }
331    }
332
333    /**
334     * Parse Contents tag. Renturns when reader reaches Contents closing tag
335     *
336     * @param reader StAX reader instance
337     * @return collection of layers within contents with properly linked TileMatrixSets
338     * @throws XMLStreamException See {@link XMLStreamReader}
339     */
340    private static Collection<Layer> parseContents(XMLStreamReader reader) throws XMLStreamException {
341        Map<String, TileMatrixSet> matrixSetById = new ConcurrentHashMap<>();
342        Collection<Layer> layers = new ArrayList<>();
343        for (int event = reader.getEventType();
344                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && new QName(WMTS_NS_URL, "Contents").equals(reader.getName()));
345                event = reader.next()) {
346            if (event == XMLStreamReader.START_ELEMENT) {
347                if (new QName(WMTS_NS_URL, "Layer").equals(reader.getName())) {
348                    layers.add(parseLayer(reader));
349                }
350                if (new QName(WMTS_NS_URL, "TileMatrixSet").equals(reader.getName())) {
351                    TileMatrixSet entry = parseTileMatrixSet(reader);
352                    matrixSetById.put(entry.identifier, entry);
353                }
354            }
355        }
356        Collection<Layer> ret = new ArrayList<>();
357        // link layers to matrix sets
358        for (Layer l: layers) {
359            for (String tileMatrixId: l.tileMatrixSetLinks) {
360                Layer newLayer = new Layer(l); // create a new layer object for each tile matrix set supported
361                newLayer.tileMatrixSet = matrixSetById.get(tileMatrixId);
362                ret.add(newLayer);
363            }
364        }
365        return ret;
366    }
367
368    /**
369     * Parse Layer tag. Returns when reader will reach Layer closing tag
370     *
371     * @param reader StAX reader instance
372     * @return Layer object, with tileMatrixSetLinks and no tileMatrixSet attribute set.
373     * @throws XMLStreamException See {@link XMLStreamReader}
374     */
375    private static Layer parseLayer(XMLStreamReader reader) throws XMLStreamException {
376        Layer layer = new Layer();
377        Stack<QName> tagStack = new Stack<>();
378
379        for (int event = reader.getEventType();
380                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && new QName(WMTS_NS_URL, "Layer").equals(reader.getName()));
381                event = reader.next()) {
382            if (event == XMLStreamReader.START_ELEMENT) {
383                tagStack.push(reader.getName());
384                if (tagStack.size() == 2) {
385                    if (new QName(WMTS_NS_URL, "Format").equals(reader.getName())) {
386                        layer.format = reader.getElementText();
387                    } else if (new QName(OWS_NS_URL, "Identifier").equals(reader.getName())) {
388                        layer.name = reader.getElementText();
389                    } else if (new QName(WMTS_NS_URL, "ResourceURL").equals(reader.getName()) &&
390                            "tile".equals(reader.getAttributeValue("", "resourceType"))) {
391                        layer.baseUrl = reader.getAttributeValue("", "template");
392                    } else if (new QName(WMTS_NS_URL, "Style").equals(reader.getName()) &&
393                            "true".equals(reader.getAttributeValue("", "isDefault"))) {
394                        if (moveReaderToTag(reader, new QName[] {new QName(OWS_NS_URL, "Identifier")})) {
395                            layer.style = reader.getElementText();
396                            tagStack.push(reader.getName()); // keep tagStack in sync
397                        }
398                    } else if (new QName(WMTS_NS_URL, "TileMatrixSetLink").equals(reader.getName())) {
399                        layer.tileMatrixSetLinks.add(praseTileMatrixSetLink(reader));
400                    } else {
401                        moveReaderToEndCurrentTag(reader);
402                    }
403                }
404            }
405            // need to get event type from reader, as parsing might have change position of reader
406            if (reader.getEventType() == XMLStreamReader.END_ELEMENT) {
407                QName start = tagStack.pop();
408                if (!start.equals(reader.getName())) {
409                    throw new IllegalStateException(tr("WMTS Parser error - start element {0} has different name than end element {2}",
410                            start, reader.getName()));
411                }
412            }
413        }
414        if (layer.style == null) {
415            layer.style = "";
416        }
417        return layer;
418    }
419
420    /**
421     * Moves the reader to the closing tag of current tag.
422     * @param reader XML stream reader positioned on XMLStreamReader.START_ELEMENT
423     * @throws XMLStreamException when parse exception occurs
424     */
425    private static void moveReaderToEndCurrentTag(XMLStreamReader reader) throws XMLStreamException {
426        int level = 0;
427        QName tag = reader.getName();
428        for (int event = reader.getEventType(); reader.hasNext(); event = reader.next()) {
429            switch (event) {
430            case XMLStreamReader.START_ELEMENT:
431                level += 1;
432                break;
433            case XMLStreamReader.END_ELEMENT:
434                level -= 1;
435                if (level == 0 && tag.equals(reader.getName())) {
436                    return;
437                }
438            }
439            if (level < 0) {
440                throw new IllegalStateException("WMTS Parser error - moveReaderToEndCurrentTag failed to find closing tag");
441            }
442        }
443        throw new IllegalStateException("WMTS Parser error - moveReaderToEndCurrentTag failed to find closing tag");
444
445    }
446
447    /**
448     * Gets TileMatrixSetLink value. Returns when reader is on TileMatrixSetLink closing tag
449     *
450     * @param reader StAX reader instance
451     * @return TileMatrixSetLink identifier
452     * @throws XMLStreamException See {@link XMLStreamReader}
453     */
454    private static String praseTileMatrixSetLink(XMLStreamReader reader) throws XMLStreamException {
455        String ret = null;
456        for (int event = reader.getEventType();
457                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT &&
458                        new QName(WMTS_NS_URL, "TileMatrixSetLink").equals(reader.getName()));
459                event = reader.next()) {
460            if (event == XMLStreamReader.START_ELEMENT && new QName(WMTS_NS_URL, "TileMatrixSet").equals(reader.getName())) {
461                ret = reader.getElementText();
462            }
463        }
464        return ret;
465    }
466
467    /**
468     * Parses TileMatrixSet section. Returns when reader is on TileMatrixSet closing tag
469     * @param reader StAX reader instance
470     * @return TileMatrixSet object
471     * @throws XMLStreamException See {@link XMLStreamReader}
472     */
473    private static TileMatrixSet parseTileMatrixSet(XMLStreamReader reader) throws XMLStreamException {
474        TileMatrixSetBuilder matrixSet = new TileMatrixSetBuilder();
475        for (int event = reader.getEventType();
476                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && new QName(WMTS_NS_URL, "TileMatrixSet").equals(reader.getName()));
477                event = reader.next()) {
478                    if (event == XMLStreamReader.START_ELEMENT) {
479                        if (new QName(OWS_NS_URL, "Identifier").equals(reader.getName())) {
480                            matrixSet.identifier = reader.getElementText();
481                        }
482                        if (new QName(OWS_NS_URL, "SupportedCRS").equals(reader.getName())) {
483                            matrixSet.crs = crsToCode(reader.getElementText());
484                        }
485                        if (new QName(WMTS_NS_URL, "TileMatrix").equals(reader.getName())) {
486                            matrixSet.tileMatrix.add(parseTileMatrix(reader, matrixSet.crs));
487                        }
488                    }
489        }
490        return matrixSet.build();
491    }
492
493    /**
494     * Parses TileMatrix section. Returns when reader is on TileMatrix closing tag.
495     * @param reader StAX reader instance
496     * @param matrixCrs projection used by this matrix
497     * @return TileMatrix object
498     * @throws XMLStreamException See {@link XMLStreamReader}
499     */
500    private static TileMatrix parseTileMatrix(XMLStreamReader reader, String matrixCrs) throws XMLStreamException {
501        Projection matrixProj = Projections.getProjectionByCode(matrixCrs);
502        TileMatrix ret = new TileMatrix();
503
504        if (matrixProj == null) {
505            // use current projection if none found. Maybe user is using custom string
506            matrixProj = Main.getProjection();
507        }
508        for (int event = reader.getEventType();
509                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && new QName(WMTS_NS_URL, "TileMatrix").equals(reader.getName()));
510                event = reader.next()) {
511            if (event == XMLStreamReader.START_ELEMENT) {
512                if (new QName(OWS_NS_URL, "Identifier").equals(reader.getName())) {
513                    ret.identifier = reader.getElementText();
514                }
515                if (new QName(WMTS_NS_URL, "ScaleDenominator").equals(reader.getName())) {
516                    ret.scaleDenominator = Double.parseDouble(reader.getElementText());
517                }
518                if (new QName(WMTS_NS_URL, "TopLeftCorner").equals(reader.getName())) {
519                    String[] topLeftCorner = reader.getElementText().split(" ");
520                    if (matrixProj.switchXY()) {
521                        ret.topLeftCorner = new EastNorth(Double.parseDouble(topLeftCorner[1]), Double.parseDouble(topLeftCorner[0]));
522                    } else {
523                        ret.topLeftCorner = new EastNorth(Double.parseDouble(topLeftCorner[0]), Double.parseDouble(topLeftCorner[1]));
524                    }
525                }
526                if (new QName(WMTS_NS_URL, "TileHeight").equals(reader.getName())) {
527                    ret.tileHeight = Integer.parseInt(reader.getElementText());
528                }
529                if (new QName(WMTS_NS_URL, "TileWidth").equals(reader.getName())) {
530                    ret.tileWidth = Integer.parseInt(reader.getElementText());
531                }
532                if (new QName(WMTS_NS_URL, "MatrixHeight").equals(reader.getName())) {
533                    ret.matrixHeight = Integer.parseInt(reader.getElementText());
534                }
535                if (new QName(WMTS_NS_URL, "MatrixWidth").equals(reader.getName())) {
536                    ret.matrixWidth = Integer.parseInt(reader.getElementText());
537                }
538            }
539        }
540        if (ret.tileHeight != ret.tileWidth) {
541            throw new AssertionError(tr("Only square tiles are supported. {0}x{1} returned by server for TileMatrix identifier {2}",
542                    ret.tileHeight, ret.tileWidth, ret.identifier));
543        }
544        return ret;
545    }
546
547    /**
548     * Parses OperationMetadata section. Returns when reader is on OperationsMetadata closing tag.
549     * Sets this.baseUrl and this.transferMode
550     *
551     * @param reader StAX reader instance
552     * @throws XMLStreamException See {@link XMLStreamReader}
553     */
554    private void parseOperationMetadata(XMLStreamReader reader) throws XMLStreamException {
555        for (int event = reader.getEventType();
556                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT &&
557                        new QName(OWS_NS_URL, "OperationsMetadata").equals(reader.getName()));
558                event = reader.next()) {
559            if (event == XMLStreamReader.START_ELEMENT) {
560                if (new QName(OWS_NS_URL, "Operation").equals(reader.getName()) && "GetTile".equals(reader.getAttributeValue("", "name")) &&
561                        moveReaderToTag(reader, new QName[]{
562                                new QName(OWS_NS_URL, "DCP"),
563                                new QName(OWS_NS_URL, "HTTP"),
564                                new QName(OWS_NS_URL, "Get"),
565
566                        })) {
567                    this.baseUrl = reader.getAttributeValue(XLINK_NS_URL, "href");
568                    this.transferMode = getTransferMode(reader);
569                }
570            }
571        }
572    }
573
574    /**
575     * Parses Operation[@name='GetTile']/DCP/HTTP/Get section. Returns when reader is on Get closing tag.
576     * @param reader StAX reader instance
577     * @return TransferMode coded in this section
578     * @throws XMLStreamException See {@link XMLStreamReader}
579     */
580    private static TransferMode getTransferMode(XMLStreamReader reader) throws XMLStreamException {
581        QName getQname = new QName(OWS_NS_URL, "Get");
582
583        Utils.ensure(getQname.equals(reader.getName()), "WMTS Parser state invalid. Expected element %s, got %s",
584                getQname, reader.getName());
585        for (int event = reader.getEventType();
586                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && getQname.equals(reader.getName()));
587                event = reader.next()) {
588            if (event == XMLStreamReader.START_ELEMENT && new QName(OWS_NS_URL, "Constraint").equals(reader.getName())
589             && "GetEncoding".equals(reader.getAttributeValue("", "name"))) {
590                moveReaderToTag(reader, new QName[]{
591                        new QName(OWS_NS_URL, "AllowedValues"),
592                        new QName(OWS_NS_URL, "Value")
593                });
594                return TransferMode.fromString(reader.getElementText());
595            }
596        }
597        return null;
598    }
599
600    /**
601     * Moves reader to first occurrence of the structure equivalent of Xpath tags[0]/tags[1]../tags[n]. If fails to find
602     * moves the reader to the closing tag of current tag
603     *
604     * @param reader StAX reader instance
605     * @param tags array of tags
606     * @return true if tag was found, false otherwise
607     * @throws XMLStreamException See {@link XMLStreamReader}
608     */
609    private static boolean moveReaderToTag(XMLStreamReader reader, QName[] tags) throws XMLStreamException {
610        QName stopTag = reader.getName();
611        int currentLevel = 0;
612        QName searchTag = tags[currentLevel];
613        QName parentTag = null;
614        QName skipTag = null;
615
616        for (int event = 0; //skip current element, so we will not skip it as a whole
617                reader.hasNext() && !(event == XMLStreamReader.END_ELEMENT && stopTag.equals(reader.getName()));
618                event = reader.next()) {
619            if (event == XMLStreamReader.END_ELEMENT && skipTag != null && skipTag.equals(reader.getName())) {
620                skipTag = null;
621            }
622            if (skipTag == null) {
623                if (event == XMLStreamReader.START_ELEMENT) {
624                    if (searchTag.equals(reader.getName())) {
625                        currentLevel += 1;
626                        if (currentLevel >= tags.length) {
627                            return true; // found!
628                        }
629                        parentTag = searchTag;
630                        searchTag = tags[currentLevel];
631                    } else {
632                        skipTag = reader.getName();
633                    }
634                }
635
636                if (event == XMLStreamReader.END_ELEMENT && parentTag != null && parentTag.equals(reader.getName())) {
637                    currentLevel -= 1;
638                    searchTag = parentTag;
639                    if (currentLevel >= 0) {
640                        parentTag = tags[currentLevel];
641                    } else {
642                        parentTag = null;
643                    }
644                }
645            }
646        }
647        return false;
648    }
649
650    private static String normalizeCapabilitiesUrl(String url) throws MalformedURLException {
651        URL inUrl = new URL(url);
652        URL ret = new URL(inUrl.getProtocol(), inUrl.getHost(), inUrl.getPort(), inUrl.getFile());
653        return ret.toExternalForm();
654    }
655
656    private static String crsToCode(String crsIdentifier) {
657        if (crsIdentifier.startsWith("urn:ogc:def:crs:")) {
658            return crsIdentifier.replaceFirst("urn:ogc:def:crs:([^:]*):.*:(.*)$", "$1:$2");
659        }
660        return crsIdentifier;
661    }
662
663    /**
664     * Initializes projection for this TileSource with projection
665     * @param proj projection to be used by this TileSource
666     */
667    public void initProjection(Projection proj) {
668        // getLayers will return only layers matching the name, if the user already choose the layer
669        // so we will not ask the user again to chose the layer, if he just changes projection
670        Collection<Layer> candidates = getLayers(currentLayer != null ? currentLayer.name : null, proj.toCode());
671        if (!candidates.isEmpty()) {
672            Layer newLayer = userSelectLayer(candidates);
673            if (newLayer != null) {
674                this.currentTileMatrixSet = newLayer.tileMatrixSet;
675                this.currentLayer = newLayer;
676                Collection<Double> scales = new ArrayList<>(currentTileMatrixSet.tileMatrix.size());
677                for (TileMatrix tileMatrix : currentTileMatrixSet.tileMatrix) {
678                    scales.add(tileMatrix.scaleDenominator * 0.28e-03);
679                }
680                this.nativeScaleList = new ScaleList(scales);
681            }
682        }
683        this.crsScale = getTileSize() * 0.28e-03 / proj.getMetersPerUnit();
684    }
685
686    /**
687     *
688     * @param name of the layer to match
689     * @param projectionCode projection code to match
690     * @return Collection of layers matching the name of the layer and projection, or only projection if name is not provided
691     */
692    private Collection<Layer> getLayers(String name, String projectionCode) {
693        Collection<Layer> ret = new ArrayList<>();
694        if (this.layers != null) {
695            for (Layer layer: this.layers) {
696                if ((name == null || name.equals(layer.name)) && (projectionCode == null || projectionCode.equals(layer.tileMatrixSet.crs))) {
697                    ret.add(layer);
698                }
699            }
700        }
701        return ret;
702    }
703
704    @Override
705    public int getTileSize() {
706        // no support for non-square tiles (tileHeight != tileWidth)
707        // and for different tile sizes at different zoom levels
708        Collection<Layer> layers = getLayers(null, Main.getProjection().toCode());
709        if (!layers.isEmpty()) {
710            return layers.iterator().next().tileMatrixSet.tileMatrix.get(0).tileHeight;
711        }
712        // if no layers is found, fallback to default mercator tile size. Maybe it will work
713        Main.warn("WMTS: Could not determine tile size. Using default tile size of: {0}", getDefaultTileSize());
714        return getDefaultTileSize();
715    }
716
717    @Override
718    public String getTileUrl(int zoom, int tilex, int tiley) {
719        String url;
720        if (currentLayer == null) {
721            return "";
722        }
723
724        if (currentLayer.baseUrl != null && transferMode == null) {
725            url = currentLayer.baseUrl;
726        } else {
727            switch (transferMode) {
728            case KVP:
729                url = baseUrl + URL_GET_ENCODING_PARAMS;
730                break;
731            case REST:
732                url = currentLayer.baseUrl;
733                break;
734            default:
735                url = "";
736                break;
737            }
738        }
739
740        TileMatrix tileMatrix = getTileMatrix(zoom);
741
742        if (tileMatrix == null) {
743            return ""; // no matrix, probably unsupported CRS selected.
744        }
745
746        return url.replaceAll("\\{layer\\}", this.currentLayer.name)
747                .replaceAll("\\{format\\}", this.currentLayer.format)
748                .replaceAll("\\{TileMatrixSet\\}", this.currentTileMatrixSet.identifier)
749                .replaceAll("\\{TileMatrix\\}", tileMatrix.identifier)
750                .replaceAll("\\{TileRow\\}", Integer.toString(tiley))
751                .replaceAll("\\{TileCol\\}", Integer.toString(tilex))
752                .replaceAll("(?i)\\{style\\}", this.currentLayer.style);
753    }
754
755    /**
756     *
757     * @param zoom zoom level
758     * @return TileMatrix that's working on this zoom level
759     */
760    private TileMatrix getTileMatrix(int zoom) {
761        if (zoom > getMaxZoom()) {
762            return null;
763        }
764        if (zoom < 0) {
765            return null;
766        }
767        return this.currentTileMatrixSet.tileMatrix.get(zoom);
768    }
769
770    @Override
771    public double getDistance(double lat1, double lon1, double lat2, double lon2) {
772        throw new UnsupportedOperationException("Not implemented");
773    }
774
775    @Override
776    public ICoordinate tileXYToLatLon(Tile tile) {
777        return tileXYToLatLon(tile.getXtile(), tile.getYtile(), tile.getZoom());
778    }
779
780    @Override
781    public ICoordinate tileXYToLatLon(TileXY xy, int zoom) {
782        return tileXYToLatLon(xy.getXIndex(), xy.getYIndex(), zoom);
783    }
784
785    @Override
786    public ICoordinate tileXYToLatLon(int x, int y, int zoom) {
787        TileMatrix matrix = getTileMatrix(zoom);
788        if (matrix == null) {
789            return Main.getProjection().getWorldBoundsLatLon().getCenter().toCoordinate();
790        }
791        double scale = matrix.scaleDenominator * this.crsScale;
792        EastNorth ret = new EastNorth(matrix.topLeftCorner.east() + x * scale, matrix.topLeftCorner.north() - y * scale);
793        return Main.getProjection().eastNorth2latlon(ret).toCoordinate();
794    }
795
796    @Override
797    public TileXY latLonToTileXY(double lat, double lon, int zoom) {
798        TileMatrix matrix = getTileMatrix(zoom);
799        if (matrix == null) {
800            return new TileXY(0, 0);
801        }
802
803        Projection proj = Main.getProjection();
804        EastNorth enPoint = proj.latlon2eastNorth(new LatLon(lat, lon));
805        double scale = matrix.scaleDenominator * this.crsScale;
806        return new TileXY(
807                (enPoint.east() - matrix.topLeftCorner.east()) / scale,
808                (matrix.topLeftCorner.north() - enPoint.north()) / scale
809                );
810    }
811
812    @Override
813    public TileXY latLonToTileXY(ICoordinate point, int zoom) {
814        return latLonToTileXY(point.getLat(),  point.getLon(), zoom);
815    }
816
817    @Override
818    public int getTileXMax(int zoom) {
819        return getTileXMax(zoom, Main.getProjection());
820    }
821
822    @Override
823    public int getTileXMin(int zoom) {
824        return 0;
825    }
826
827    @Override
828    public int getTileYMax(int zoom) {
829        return getTileYMax(zoom, Main.getProjection());
830    }
831
832    @Override
833    public int getTileYMin(int zoom) {
834        return 0;
835    }
836
837    @Override
838    public Point latLonToXY(double lat, double lon, int zoom) {
839        TileMatrix matrix = getTileMatrix(zoom);
840        if (matrix == null) {
841            return new Point(0, 0);
842        }
843        double scale = matrix.scaleDenominator * this.crsScale;
844        EastNorth point = Main.getProjection().latlon2eastNorth(new LatLon(lat, lon));
845        return new Point(
846                    (int) Math.round((point.east() - matrix.topLeftCorner.east())   / scale),
847                    (int) Math.round((matrix.topLeftCorner.north() - point.north()) / scale)
848                );
849    }
850
851    @Override
852    public Point latLonToXY(ICoordinate point, int zoom) {
853        return latLonToXY(point.getLat(), point.getLon(), zoom);
854    }
855
856    @Override
857    public Coordinate xyToLatLon(Point point, int zoom) {
858        return xyToLatLon(point.x, point.y, zoom);
859    }
860
861    @Override
862    public Coordinate xyToLatLon(int x, int y, int zoom) {
863        TileMatrix matrix = getTileMatrix(zoom);
864        if (matrix == null) {
865            return new Coordinate(0, 0);
866        }
867        double scale = matrix.scaleDenominator * this.crsScale;
868        Projection proj = Main.getProjection();
869        EastNorth ret = new EastNorth(
870                matrix.topLeftCorner.east() + x * scale,
871                matrix.topLeftCorner.north() - y * scale
872                );
873        LatLon ll = proj.eastNorth2latlon(ret);
874        return new Coordinate(ll.lat(), ll.lon());
875    }
876
877    @Override
878    public Map<String, String> getHeaders() {
879        return headers;
880    }
881
882    @Override
883    public int getMaxZoom() {
884        if (this.currentTileMatrixSet != null) {
885            return this.currentTileMatrixSet.tileMatrix.size()-1;
886        }
887        return 0;
888    }
889
890    @Override
891    public String getTileId(int zoom, int tilex, int tiley) {
892        return getTileUrl(zoom, tilex, tiley);
893    }
894
895    /**
896     * Checks if url is acceptable by this Tile Source
897     * @param url URL to check
898     */
899    public static void checkUrl(String url) {
900        CheckParameterUtil.ensureParameterNotNull(url, "url");
901        Matcher m = Pattern.compile("\\{[^}]*\\}").matcher(url);
902        while (m.find()) {
903            boolean isSupportedPattern = false;
904            for (String pattern : ALL_PATTERNS) {
905                if (m.group().matches(pattern)) {
906                    isSupportedPattern = true;
907                    break;
908                }
909            }
910            if (!isSupportedPattern) {
911                throw new IllegalArgumentException(
912                        tr("{0} is not a valid WMS argument. Please check this server URL:\n{1}", m.group(), url));
913            }
914        }
915    }
916
917    /**
918     * @return set of projection codes that this TileSource supports
919     */
920    public Set<String> getSupportedProjections() {
921        Set<String> ret = new HashSet<>();
922        if (currentLayer == null) {
923            for (Layer layer: this.layers) {
924                ret.add(layer.tileMatrixSet.crs);
925            }
926        } else {
927            for (Layer layer: this.layers) {
928                if (currentLayer.name.equals(layer.name)) {
929                    ret.add(layer.tileMatrixSet.crs);
930                }
931            }
932        }
933        return ret;
934    }
935
936    private int getTileYMax(int zoom, Projection proj) {
937        TileMatrix matrix = getTileMatrix(zoom);
938        if (matrix == null) {
939            return 0;
940        }
941
942        if (matrix.matrixHeight != -1) {
943            return matrix.matrixHeight;
944        }
945
946        double scale = matrix.scaleDenominator * this.crsScale;
947        EastNorth min = matrix.topLeftCorner;
948        EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax());
949        return (int) Math.ceil(Math.abs(max.north() - min.north()) / scale);
950    }
951
952    private int getTileXMax(int zoom, Projection proj) {
953        TileMatrix matrix = getTileMatrix(zoom);
954        if (matrix == null) {
955            return 0;
956        }
957        if (matrix.matrixWidth != -1) {
958            return matrix.matrixWidth;
959        }
960
961        double scale = matrix.scaleDenominator * this.crsScale;
962        EastNorth min = matrix.topLeftCorner;
963        EastNorth max = proj.latlon2eastNorth(proj.getWorldBoundsLatLon().getMax());
964        return (int) Math.ceil(Math.abs(max.east() - min.east()) / scale);
965    }
966
967    /**
968     * Get native scales of tile source.
969     * @return {@link ScaleList} of native scales
970     */
971    public ScaleList getNativeScales() {
972        return nativeScaleList;
973    }
974
975}