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.text.DecimalFormat;
007import java.text.DecimalFormatSymbols;
008import java.text.NumberFormat;
009import java.util.Arrays;
010import java.util.Locale;
011import java.util.Map;
012import java.util.Set;
013import java.util.TreeSet;
014import java.util.concurrent.ConcurrentHashMap;
015import java.util.regex.Matcher;
016import java.util.regex.Pattern;
017
018import org.openstreetmap.gui.jmapviewer.interfaces.TemplatedTileSource;
019import org.openstreetmap.josm.data.coor.EastNorth;
020import org.openstreetmap.josm.data.projection.Projection;
021import org.openstreetmap.josm.data.projection.ProjectionRegistry;
022import org.openstreetmap.josm.gui.layer.WMSLayer;
023import org.openstreetmap.josm.tools.CheckParameterUtil;
024import org.openstreetmap.josm.tools.Utils;
025
026/**
027 * Tile Source handling WMS providers
028 *
029 * @author Wiktor Niesiobędzki
030 * @since 8526
031 */
032public class TemplatedWMSTileSource extends AbstractWMSTileSource implements TemplatedTileSource {
033    // CHECKSTYLE.OFF: SingleSpaceSeparator
034    private static final Pattern PATTERN_HEADER = Pattern.compile("\\{header\\(([^,]+),([^}]+)\\)\\}");
035    private static final Pattern PATTERN_PROJ   = Pattern.compile("\\{proj\\}");
036    private static final Pattern PATTERN_WKID   = Pattern.compile("\\{wkid\\}");
037    private static final Pattern PATTERN_BBOX   = Pattern.compile("\\{bbox\\}");
038    private static final Pattern PATTERN_W      = Pattern.compile("\\{w\\}");
039    private static final Pattern PATTERN_S      = Pattern.compile("\\{s\\}");
040    private static final Pattern PATTERN_E      = Pattern.compile("\\{e\\}");
041    private static final Pattern PATTERN_N      = Pattern.compile("\\{n\\}");
042    private static final Pattern PATTERN_WIDTH  = Pattern.compile("\\{width\\}");
043    private static final Pattern PATTERN_HEIGHT = Pattern.compile("\\{height\\}");
044    private static final Pattern PATTERN_TIME   = Pattern.compile("\\{time\\}"); // Sentinel-2
045    private static final Pattern PATTERN_PARAM  = Pattern.compile("\\{([^}]+)\\}");
046    // CHECKSTYLE.ON: SingleSpaceSeparator
047
048    private static final NumberFormat LATLON_FORMAT = new DecimalFormat("###0.0000000", new DecimalFormatSymbols(Locale.US));
049
050    private static final Pattern[] ALL_PATTERNS = {
051            PATTERN_HEADER, PATTERN_PROJ, PATTERN_WKID, PATTERN_BBOX,
052            PATTERN_W, PATTERN_S, PATTERN_E, PATTERN_N,
053            PATTERN_WIDTH, PATTERN_HEIGHT, PATTERN_TIME,
054    };
055
056    private final Set<String> serverProjections;
057    private final Map<String, String> headers = new ConcurrentHashMap<>();
058    private final String date;
059    private final boolean switchLatLon;
060
061    /**
062     * Creates a tile source based on imagery info
063     * @param info imagery info
064     * @param tileProjection the tile projection
065     */
066    public TemplatedWMSTileSource(ImageryInfo info, Projection tileProjection) {
067        super(info, tileProjection);
068        this.serverProjections = new TreeSet<>(info.getServerProjections());
069        this.headers.putAll(info.getCustomHttpHeaders());
070        this.date = info.getDate();
071        handleTemplate();
072        initProjection();
073        // Bounding box coordinates have to be switched for WMS 1.3.0 EPSG:4326.
074        //
075        // Background:
076        //
077        // bbox=x_min,y_min,x_max,y_max
078        //
079        //      SRS=... is WMS 1.1.1
080        //      CRS=... is WMS 1.3.0
081        //
082        // The difference:
083        //      For SRS x is east-west and y is north-south
084        //      For CRS x and y are as specified by the EPSG
085        //          E.g. [1] lists lat as first coordinate axis and lot as second, so it is switched for EPSG:4326.
086        //          For most other EPSG code there seems to be no difference.
087        // CHECKSTYLE.OFF: LineLength
088        // [1] https://www.epsg-registry.org/report.htm?type=selection&entity=urn:ogc:def:crs:EPSG::4326&reportDetail=short&style=urn:uuid:report-style:default-with-code&style_name=OGP%20Default%20With%20Code&title=EPSG:4326
089        // CHECKSTYLE.ON: LineLength
090        if (baseUrl.toLowerCase(Locale.US).contains("crs=epsg:4326")) {
091            switchLatLon = true;
092        } else if (baseUrl.toLowerCase(Locale.US).contains("crs=")) {
093            // assume WMS 1.3.0
094            switchLatLon = ProjectionRegistry.getProjection().switchXY();
095        } else {
096            switchLatLon = false;
097        }
098    }
099
100    @Override
101    public int getDefaultTileSize() {
102        return WMSLayer.PROP_IMAGE_SIZE.get();
103    }
104
105    @Override
106    public String getTileUrl(int zoom, int tilex, int tiley) {
107        String myProjCode = getServerCRS();
108
109        EastNorth nw = getTileEastNorth(tilex, tiley, zoom);
110        EastNorth se = getTileEastNorth(tilex + 1, tiley + 1, zoom);
111
112        double w = nw.getX();
113        double n = nw.getY();
114
115        double s = se.getY();
116        double e = se.getX();
117
118        if ("EPSG:4326".equals(myProjCode) && !serverProjections.contains(myProjCode) && serverProjections.contains("CRS:84")) {
119            myProjCode = "CRS:84";
120        }
121
122        // Using StringBuffer and generic PATTERN_PARAM matcher gives 2x performance improvement over replaceAll
123        StringBuffer url = new StringBuffer(baseUrl.length());
124        Matcher matcher = PATTERN_PARAM.matcher(baseUrl);
125        while (matcher.find()) {
126            String replacement;
127            switch (matcher.group(1)) {
128            case "proj":
129                replacement = myProjCode;
130                break;
131            case "wkid":
132                replacement = myProjCode.startsWith("EPSG:") ? myProjCode.substring(5) : myProjCode;
133                break;
134            case "bbox":
135                replacement = getBbox(zoom, tilex, tiley, switchLatLon);
136                break;
137            case "w":
138                replacement = LATLON_FORMAT.format(w);
139                break;
140            case "s":
141                replacement = LATLON_FORMAT.format(s);
142                break;
143            case "e":
144                replacement = LATLON_FORMAT.format(e);
145                break;
146            case "n":
147                replacement = LATLON_FORMAT.format(n);
148                break;
149            case "width":
150            case "height":
151                replacement = String.valueOf(getTileSize());
152                break;
153            case "time":
154                replacement = Utils.encodeUrl(date);
155                break;
156            default:
157                replacement = '{' + matcher.group(1) + '}';
158            }
159            matcher.appendReplacement(url, replacement);
160        }
161        matcher.appendTail(url);
162        return url.toString().replace(" ", "%20");
163    }
164
165    @Override
166    public String getTileId(int zoom, int tilex, int tiley) {
167        return getTileUrl(zoom, tilex, tiley);
168    }
169
170    @Override
171    public Map<String, String> getHeaders() {
172        return headers;
173    }
174
175    /**
176     * Checks if url is acceptable by this Tile Source
177     * @param url URL to check
178     */
179    public static void checkUrl(String url) {
180        CheckParameterUtil.ensureParameterNotNull(url, "url");
181        Matcher m = PATTERN_PARAM.matcher(url);
182        while (m.find()) {
183            boolean isSupportedPattern = Arrays.stream(ALL_PATTERNS)
184                    .anyMatch(pattern -> pattern.matcher(m.group()).matches());
185            if (!isSupportedPattern) {
186                throw new IllegalArgumentException(
187                        tr("{0} is not a valid WMS argument. Please check this server URL:\n{1}", m.group(), url));
188            }
189        }
190    }
191
192    private void handleTemplate() {
193        // Capturing group pattern on switch values
194        StringBuffer output = new StringBuffer();
195        Matcher matcher = PATTERN_HEADER.matcher(this.baseUrl);
196        while (matcher.find()) {
197            headers.put(matcher.group(1), matcher.group(2));
198            matcher.appendReplacement(output, "");
199        }
200        matcher.appendTail(output);
201        this.baseUrl = output.toString();
202    }
203}