Index: /applications/viewer/jmapviewer/src/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSource.java
===================================================================
--- /applications/viewer/jmapviewer/src/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSource.java	(revision 34714)
+++ /applications/viewer/jmapviewer/src/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSource.java	(revision 34715)
@@ -34,17 +34,20 @@
     private String[] randomParts;
     private final Map<String, String> headers = new HashMap<>();
+    private boolean inverse_zoom = false;
+    private int zoom_offset = 0;
 
     // CHECKSTYLE.OFF: SingleSpaceSeparator
     private static final String COOKIE_HEADER   = "Cookie";
-    private static final String PATTERN_ZOOM    = "\\{(?:(\\d+)-)?z(?:oom)?([+-]\\d+)?\\}";
-    private static final String PATTERN_X       = "\\{x\\}";
-    private static final String PATTERN_Y       = "\\{y\\}";
-    private static final String PATTERN_Y_YAHOO = "\\{!y\\}";
-    private static final String PATTERN_NEG_Y   = "\\{-y\\}";
-    private static final String PATTERN_SWITCH  = "\\{switch:([^}]+)\\}";
-    private static final String PATTERN_HEADER  = "\\{header\\(([^,]+),([^}]+)\\)\\}";
+    private static final Pattern PATTERN_ZOOM    = Pattern.compile("\\{(?:(\\d+)-)?z(?:oom)?([+-]\\d+)?\\}");
+    private static final Pattern PATTERN_X       = Pattern.compile("\\{x\\}");
+    private static final Pattern PATTERN_Y       = Pattern.compile("\\{y\\}");
+    private static final Pattern PATTERN_Y_YAHOO = Pattern.compile("\\{!y\\}");
+    private static final Pattern PATTERN_NEG_Y   = Pattern.compile("\\{-y\\}");
+    private static final Pattern PATTERN_SWITCH  = Pattern.compile("\\{switch:([^}]+)\\}");
+    private static final Pattern PATTERN_HEADER  = Pattern.compile("\\{header\\(([^,]+),([^}]+)\\)\\}");
+    private static final Pattern PATTERN_PARAM  = Pattern.compile("\\{((?:\\d+-)?z(?:oom)?(:?[+-]\\d+)?|x|y|!y|-y|switch:([^}]+))\\}");
     // CHECKSTYLE.ON: SingleSpaceSeparator
 
-    private static final String[] ALL_PATTERNS = {
+    private static final Pattern[] ALL_PATTERNS = {
         PATTERN_HEADER, PATTERN_ZOOM, PATTERN_X, PATTERN_Y, PATTERN_Y_YAHOO, PATTERN_NEG_Y, PATTERN_SWITCH
     };
@@ -65,12 +68,11 @@
     private void handleTemplate() {
         // Capturing group pattern on switch values
-        Matcher m = Pattern.compile(".*"+PATTERN_SWITCH+".*").matcher(baseUrl);
-        if (m.matches()) {
+        Matcher m = PATTERN_SWITCH.matcher(baseUrl);
+        if (m.find()) {
             rand = new Random();
             randomParts = m.group(1).split(",");
         }
-        Pattern pattern = Pattern.compile(PATTERN_HEADER);
         StringBuffer output = new StringBuffer();
-        Matcher matcher = pattern.matcher(baseUrl);
+        Matcher matcher = PATTERN_HEADER.matcher(baseUrl);
         while (matcher.find()) {
             headers.put(matcher.group(1), matcher.group(2));
@@ -79,4 +81,18 @@
         matcher.appendTail(output);
         baseUrl = output.toString();
+        m = PATTERN_ZOOM.matcher(this.baseUrl);
+        if (m.find()) {
+            if (m.group(1) != null) {
+                inverse_zoom = true;
+                zoom_offset = Integer.parseInt(m.group(1));
+            }
+            if (m.group(2) != null) {
+                String ofs = m.group(2);
+                if (ofs.startsWith("+"))
+                    ofs = ofs.substring(1);
+                zoom_offset += Integer.parseInt(ofs);
+            }
+        }
+
     }
 
@@ -88,27 +104,42 @@
     @Override
     public String getTileUrl(int zoom, int tilex, int tiley) {
-        int finalZoom = zoom;
-        Matcher m = Pattern.compile(".*"+PATTERN_ZOOM+".*").matcher(this.baseUrl);
-        if (m.matches()) {
-            if (m.group(1) != null) {
-                finalZoom = Integer.parseInt(m.group(1))-zoom;
+        StringBuffer url = new StringBuffer(baseUrl.length());
+        Matcher matcher = PATTERN_PARAM.matcher(baseUrl);
+        while (matcher.find()) {
+            String replacement = "replace";
+            switch (matcher.group(1)) {
+            case "z": // PATTERN_ZOOM
+            case "zoom":
+                replacement = Integer.toString((inverse_zoom ? -1 * zoom : zoom) + zoom_offset);
+                break;
+            case "x": // PATTERN_X
+                replacement = Integer.toString(tilex);
+                break;
+            case "y": // PATTERN_Y
+                replacement = Integer.toString(tiley);
+                break;
+            case "!y": // PATTERN_Y_YAHOO
+                replacement = Integer.toString((int) Math.pow(2, zoom-1)-1-tiley);
+                break;
+            case "-y": // PATTERN_NEG_Y
+                replacement = Integer.toString((int) Math.pow(2, zoom)-1-tiley);
+                break;
+            case "switch:":
+                replacement = randomParts[rand.nextInt(randomParts.length)];
+                break;
+            default:
+                // handle switch/zoom here, as group will contain parameters and switch will not work
+                if (PATTERN_ZOOM.matcher("{" + matcher.group(1) + "}").matches()) {
+                    replacement = Integer.toString((inverse_zoom ? -1 * zoom : zoom) + zoom_offset);
+                } else if (PATTERN_SWITCH.matcher("{" + matcher.group(1) + "}").matches()) {
+                    replacement = randomParts[rand.nextInt(randomParts.length)];
+                } else {
+                    replacement = '{' + matcher.group(1) + '}';
+                }
             }
-            if (m.group(2) != null) {
-                String ofs = m.group(2);
-                if (ofs.startsWith("+"))
-                    ofs = ofs.substring(1);
-                finalZoom += Integer.parseInt(ofs);
-            }
+            matcher.appendReplacement(url, replacement);
         }
-        String r = this.baseUrl
-            .replaceAll(PATTERN_ZOOM, Integer.toString(finalZoom))
-            .replaceAll(PATTERN_X, Integer.toString(tilex))
-            .replaceAll(PATTERN_Y, Integer.toString(tiley))
-            .replaceAll(PATTERN_Y_YAHOO, Integer.toString((int) Math.pow(2, zoom-1)-1-tiley))
-            .replaceAll(PATTERN_NEG_Y, Integer.toString((int) Math.pow(2, zoom)-1-tiley));
-        if (rand != null) {
-            r = r.replaceAll(PATTERN_SWITCH, randomParts[rand.nextInt(randomParts.length)]);
-        }
-        return r;
+        matcher.appendTail(url);
+        return url.toString().replace(" ", "%20");
     }
 
@@ -122,6 +153,6 @@
         while (m.find()) {
             boolean isSupportedPattern = false;
-            for (String pattern : ALL_PATTERNS) {
-                if (m.group().matches(pattern)) {
+            for (Pattern pattern : ALL_PATTERNS) {
+                if (pattern.matcher(m.group()).matches()) {
                     isSupportedPattern = true;
                     break;
Index: /applications/viewer/jmapviewer/test/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourceTest.java
===================================================================
--- /applications/viewer/jmapviewer/test/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourceTest.java	(revision 34715)
+++ /applications/viewer/jmapviewer/test/org/openstreetmap/gui/jmapviewer/tilesources/TemplatedTMSTileSourceTest.java	(revision 34715)
@@ -0,0 +1,198 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.gui.jmapviewer.tilesources;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import java.text.MessageFormat;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.tuple.Triple;
+import org.junit.Test;
+import org.openstreetmap.josm.data.imagery.ImageryInfo;
+
+
+/**
+ *
+ * Tests for TemplaedTMSTileSource
+ */
+public class TemplatedTMSTileSourceTest {
+
+    private final static Collection<String> TMS_IMAGERIES = Arrays.asList(new String[]{
+            "http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z={zoom}&x={x}&y={-y}",
+            /*
+             *  generate for example with:
+             *  $ curl https://josm.openstreetmap.de/maps | \
+             *    xmlstarlet sel -N 'josm=http://josm.openstreetmap.de/maps-1.0' -t -v "//josm:entry[josm:type='tms']/josm:url" -n  | \
+             *    sed -e 's/\&amp;/\&/g' -e 's/^/"/' -e 's/$/",/'
+             */
+    });
+
+    /**
+     * triple of:
+     *  * baseUrl
+     *  * expected tile url for zoom=1, x=2, y=3
+     *  * expected tile url for zoom=3, x=2, y=1
+     */
+    @SuppressWarnings("unchecked")
+    private Collection<Triple<String, String, String>> TEST_DATA = Arrays.asList(new Triple[] {
+            Triple.of("http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z={zoom}&x={x}&y={-y}", "http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z=1&x=2&y=-2", "http://imagico.de/map/osmim_tiles.php?layer=S2A_R136_N41_20150831T093006&z=3&x=2&y=6"),
+            /*
+             * generate with main method below once TMS_IMAGERIES is filled in
+             */
+    });
+
+    /**
+     * Check standard template
+     */
+    @Test
+    public void testGetTileUrl() {
+        checkGetTileUrl(
+                "http://localhost/{z}/{x}/{y}",
+                "http://localhost/1/2/3",
+                "http://localhost/3/1/2"
+                );
+    }
+
+
+    /**
+     * Check template with positive zoom index
+     */
+    @Test
+    public void testGetTileUrl_positive_zoom() {
+        checkGetTileUrl(
+                "http://localhost/{zoom+5}/{x}/{y}",
+                "http://localhost/6/2/3",
+                "http://localhost/8/1/2"
+                );
+    }
+
+    /**
+     * Check template with negative zoom index
+     */
+    @Test
+    public void testGetTileUrl_negative_zoom() {
+        checkGetTileUrl(
+                "http://localhost/{zoom-5}/{x}/{y}",
+                "http://localhost/-4/2/3",
+                "http://localhost/-2/1/2"
+                );
+    }
+
+    /**
+     * Check template with inverse zoom index
+     */
+    @Test
+    public void testGetTileUrl_inverse_negative_zoom() {
+        checkGetTileUrl(
+                "http://localhost/{5-zoom}/{x}/{y}",
+                "http://localhost/4/2/3",
+                "http://localhost/2/1/2"
+                );
+    }
+
+    /**
+     * Check template with inverse zoom index and negative zoom index
+     */
+    @Test
+    public void testGetTileUrl_both_offsets() {
+        checkGetTileUrl(
+                "http://localhost/{10-zoom-5}/{x}/{y}",
+                "http://localhost/4/2/3",
+                "http://localhost/2/1/2"
+                );
+    }
+
+    /**
+     * Test template with switch
+     */
+    @Test
+    public void testGetTileUrl_switch() {
+        ImageryInfo testImageryTMS = new ImageryInfo("test imagery", "http://{switch:a,b,c}.localhost/{10-zoom-5}/{x}/{y}", "tms", null, null);
+        TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/4/2/3",
+                        "http://b.localhost/4/2/3",
+                        "http://c.localhost/4/2/3"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(1, 2, 3)))
+                );
+
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/3/3/4",
+                        "http://b.localhost/3/3/4",
+                        "http://c.localhost/3/3/4"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(2, 3, 4)))
+                );
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/2/4/5",
+                        "http://b.localhost/2/4/5",
+                        "http://c.localhost/2/4/5"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(3, 4, 5)))
+                );
+        assertTrue(
+                Stream.of(
+                        "http://a.localhost/1/5/6",
+                        "http://b.localhost/1/5/6",
+                        "http://c.localhost/1/5/6"
+                        )
+                .anyMatch(Predicate.isEqual(ts.getTileUrl(4, 5, 6)))
+                );
+    }
+
+    @Test
+    public void testGetTileUrl_yahoo() {
+        checkGetTileUrl(
+                "http://localhost/{z}/{x}/{!y}",
+                "http://localhost/1/2/-3",
+                "http://localhost/3/1/1"
+                );
+
+    }
+
+    @Test
+    public void testGetTileUrl_negative_y() {
+        checkGetTileUrl(
+                "http://localhost/{z}/{x}/{-y}",
+                "http://localhost/1/2/-2",
+                "http://localhost/3/1/5"
+                );
+
+    }
+
+    private void checkGetTileUrl(String url, String expected123, String expected312) {
+        ImageryInfo testImageryTMS = new ImageryInfo("test imagery", url, "tms", null, null);
+        TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+        assertEquals(expected123, ts.getTileUrl(1, 2, 3));
+        assertEquals(expected312, ts.getTileUrl(3, 1, 2));
+    }
+    /**
+     * Tests all entries in TEST_DATA. This test will fail if {switch:...} template is used
+     */
+    @Test
+    public void testAllUrls() {
+        for(Triple<String, String, String> test: TEST_DATA) {
+            ImageryInfo testImageryTMS = new ImageryInfo("test imagery", test.getLeft(), "tms", null, null);
+            TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+            assertEquals(test.getMiddle(), ts.getTileUrl(1, 2, 3));
+            assertEquals(test.getRight(), ts.getTileUrl(3, 2, 1));
+        }
+    }
+
+    public static void main(String[] args) {
+        for(String url: TMS_IMAGERIES) {
+            ImageryInfo testImageryTMS = new ImageryInfo("test imagery", url, "tms", null, null);
+            TemplatedTMSTileSource ts = new TemplatedTMSTileSource(testImageryTMS);
+            System.out.println(MessageFormat.format("Triple.of(\"{0}\", \"{1}\", \"{2}\"),", url, ts.getTileUrl(1, 2, 3), ts.getTileUrl(3, 2, 1)));
+        }
+    }
+
+}
