diff --git a/README_light.md b/README_light.md
index 2e019ff..4891235 100644
--- a/README_light.md
+++ b/README_light.md
@@ -28,8 +28,8 @@ docker build -t tileserver-gl-light .
[Download from OpenMapTiles.com](https://openmaptiles.com/downloads/planet/) or [create](https://github.com/openmaptiles/openmaptiles) your vector tile, and run following in directory contains your *.mbtiles.
```
-docker run --rm -it -v $(pwd):/data -p 8000:80 tileserver-gl-light
+docker run --rm -it -v $(pwd):/data -p 8080:8080 tileserver-gl-light
```
## Documentation
-You can read full documentation of this project at https://tileserver.readthedocs.io/.
\ No newline at end of file
+You can read full documentation of this project at https://tileserver.readthedocs.io/.
diff --git a/docs/config.rst b/docs/config.rst
index 2164080..e302dca 100644
--- a/docs/config.rst
+++ b/docs/config.rst
@@ -33,6 +33,7 @@ Example:
"serveAllStyles": false,
"serveStaticMaps": true,
"allowRemoteMarkerIcons": true,
+ "languages": ["en", "fr", "it"],
"tileMargin": 0
},
"styles": {
@@ -150,6 +151,13 @@ Allows the rendering of marker icons fetched via http(s) hyperlinks.
For security reasons only allow this if you can control the origins from where the markers are fetched!
Default is to disallow fetching of icons from remote sources.
+``languages``
+--------------
+
+Allows translating labels when rendering static maps. This is a list of allowed languages.
+Note that your vector tile source needs to contain the translated labels (e.g. ``name:en``, ``name:fr``...).
+Not used by default.
+
``styles``
==========
diff --git a/docs/endpoints.rst b/docs/endpoints.rst
index 6dcb5ab..3eb3823 100644
--- a/docs/endpoints.rst
+++ b/docs/endpoints.rst
@@ -27,13 +27,13 @@ WMTS Capabilities
Static images
=============
-* Several endpoints:
+* There are three endpoints depending on how you want to define the map location:
- * ``/styles/{id}/static/{lon},{lat},{zoom}[@{bearing}[,{pitch}]]/{width}x{height}[@2x].{format}`` (center-based)
- * ``/styles/{id}/static/{minx},{miny},{maxx},{maxy}/{width}x{height}[@2x].{format}`` (area-based)
- * ``/styles/{id}/static/auto/{width}x{height}[@2x].{format}`` (autofit path -- see below)
+ * Center and zoom level: ``/styles/{id}/static/{lon},{lat},{zoom}[@{bearing}[,{pitch}]]/{width}x{height}[@2x].{format}``
+ * Bounds, e.g. latitude/longitude of corners: ``/styles/{id}/static/{minx},{miny},{maxx},{maxy}/{width}x{height}[@2x].{format}``
+ * Autofit to a path: ``/styles/{id}/static/auto/{width}x{height}[@2x].{format}``
-* All the static image endpoints additionally support following query parameters:
+* All these endpoints accept these query parameters:
* ``path`` - ``((fill|stroke|width)\:[^\|]+\|)*((enc:.+)|((-?\d+\.?\d*,-?\d+\.?\d*\|)+(-?\d+\.?\d*,-?\d+\.?\d*)))``
@@ -50,7 +50,7 @@ Static images
* e.g. ``path=stroke:yellow|width:2|fill:green|5.9,45.8|5.9,47.8|10.5,47.8|10.5,45.8|5.9,45.8`` or ``path=stroke:blue|width:1|fill:yellow|enc:_p~iF~ps|U_ulLnnqC_mqNvxq`@``
- * can be provided multiple times
+ * can be provided multiple times
* ``latlng`` - indicates coordinates are in ``lat,lng`` order rather than the usual ``lng,lat``
* ``fill`` - color to use as the fill (e.g. ``red``, ``rgba(255,255,255,0.5)``, ``#0000ff``)
@@ -84,6 +84,7 @@ Static images
* value of ``0.1`` means "add 10% size to each side to make sure the area of interest is nicely visible"
* ``maxzoom`` - Maximum zoom level (only for auto endpoint where zoom level is calculated and not provided)
+ * ``language`` - Language code to translate all labels from the selected style. If no language is set or if the language is not found in ``options.languages``, no translation happens.
* You can also use (experimental) ``/styles/{id}/static/raw/...`` endpoints with raw spherical mercator coordinates (EPSG:3857) instead of WGS84.
diff --git a/public/templates/index.tmpl b/public/templates/index.tmpl
index 26b6942..d826129 100644
--- a/public/templates/index.tmpl
+++ b/public/templates/index.tmpl
@@ -43,6 +43,9 @@
{{#if serving_rendered}}
| WMTS
{{/if}}
+ {{#if serving_rendered}}
+ | Static
+ {{/if}}
{{#if xyz_link}}
| XYZ
diff --git a/src/serve_rendered.js b/src/serve_rendered.js
index fc9982f..c1513e1 100644
--- a/src/serve_rendered.js
+++ b/src/serve_rendered.js
@@ -19,6 +19,7 @@ import polyline from '@mapbox/polyline';
import proj4 from 'proj4';
import request from 'request';
import { getFontsPbf, getTileUrls, fixTileJSONCenter } from './utils.js';
+import translateLayers from './translate_layers.js';
const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)';
const PATH_PATTERN =
@@ -202,6 +203,19 @@ const extractPathsFromQuery = (query, transformer) => {
return paths;
};
+/**
+ * Parses language provided via query.
+ * @param {object} query Request query parameters.
+ * @param {object} options Configuration options.
+ */
+const extractLanguageFromQuery = (query, options) => {
+ const languages = options.languages || [];
+ if ('language' in query && languages.includes(query.language)) {
+ return query.language;
+ }
+ return null;
+};
+
/**
* Parses marker options provided via query and sets corresponding attributes
* on marker object.
@@ -330,7 +344,7 @@ const precisePx = (ll, zoom) => {
};
/**
- * Draws a marker in cavans context.
+ * Draws a marker in canvas context.
* @param {object} ctx Canvas context object.
* @param {object} marker Marker object parsed by extractMarkersFromQuery.
* @param {number} z Map zoom level.
@@ -646,6 +660,7 @@ export const serve_rendered = {
next,
opt_overlay,
opt_mode = 'tile',
+ language = null,
) => {
if (
Math.abs(lon) > 180 ||
@@ -677,7 +692,7 @@ export const serve_rendered = {
if (opt_mode === 'tile' && tileMargin === 0) {
pool = item.map.renderers[scale];
} else {
- pool = item.map.renderers_static[scale];
+ pool = item.map.renderers_static[`${scale}${language}`];
}
pool.acquire((err, renderer) => {
const mlglZ = Math.max(0, z - 1);
@@ -712,6 +727,7 @@ export const serve_rendered = {
// Fix semi-transparent outlines on raw, premultiplied input
// https://github.com/maptiler/tileserver-gl/issues/350#issuecomment-477857040
+ // FIXME: unnecessay now? https://github.com/lovell/sharp/issues/1599#issuecomment-837004081
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
const norm = alpha / 255;
@@ -901,6 +917,7 @@ export const serve_rendered = {
}
const paths = extractPathsFromQuery(req.query, transformer);
+ const language = extractLanguageFromQuery(req.query, options);
const markers = extractMarkersFromQuery(
req.query,
options,
@@ -935,6 +952,7 @@ export const serve_rendered = {
next,
overlay,
'static',
+ language,
);
} catch (e) {
next(e);
@@ -983,6 +1001,7 @@ export const serve_rendered = {
const pitch = 0;
const paths = extractPathsFromQuery(req.query, transformer);
+ const language = extractLanguageFromQuery(req.query, options);
const markers = extractMarkersFromQuery(
req.query,
options,
@@ -1016,6 +1035,7 @@ export const serve_rendered = {
next,
overlay,
'static',
+ language,
);
} catch (e) {
next(e);
@@ -1077,6 +1097,7 @@ export const serve_rendered = {
: item.dataProjWGStoInternalWGS;
const paths = extractPathsFromQuery(req.query, transformer);
+ const language = extractLanguageFromQuery(req.query, options);
const markers = extractMarkersFromQuery(
req.query,
options,
@@ -1150,6 +1171,7 @@ export const serve_rendered = {
next,
overlay,
'static',
+ language,
);
} catch (e) {
next(e);
@@ -1184,14 +1206,16 @@ export const serve_rendered = {
};
let styleJSON;
- const createPool = (ratio, mode, min, max) => {
- const createRenderer = (ratio, createCallback) => {
+ const createPool = (scale, mode, min, max, language) => {
+ const createRenderer = (createCallback) => {
const renderer = new mlgl.Map({
mode: mode,
- ratio: ratio,
+ ratio: scale,
request: (req, callback) => {
const protocol = req.url.split(':')[0];
- // console.log('Handling request:', req);
+ if (options.verbose) {
+ console.log('[VERBOSE] Handling request:', req);
+ }
if (protocol === 'sprites') {
const dir = options.paths[protocol];
const file = unescape(req.url).substring(protocol.length + 3);
@@ -1282,7 +1306,9 @@ export const serve_rendered = {
const extension = path.extname(parts.pathname).toLowerCase();
const format = extensionToFormat[extension] || '';
if (err || res.statusCode < 200 || res.statusCode >= 300) {
- // console.log('HTTP error', err || res.statusCode);
+ if (options.verbose) {
+ console.log('HTTP error', err || res.statusCode);
+ }
createEmptyResponse(format, '', callback);
return;
}
@@ -1305,13 +1331,37 @@ export const serve_rendered = {
}
},
});
- renderer.load(styleJSON);
+
+ if (options.verbose) {
+ console.log('[VERBOSE] createRenderer', scale, mode, language);
+ }
+
+ let rendererStyle = styleJSON;
+ if (language) {
+ const layers = [...styleJSON.layers];
+ rendererStyle = { ...rendererStyle, layers };
+
+ const translator = {
+ getLayoutProperty: (id, key) =>
+ layers.find((layer) => layer.id === id).layout[key],
+ setLayoutProperty: (id, key, value) => {
+ const i = layers.findIndex((layer) => layer.id === id);
+ const layer = { ...layers[i] };
+ layers[i] = layer;
+ layer.layout = { ...layer.layout, [key]: value };
+ },
+ translateLayers,
+ };
+ translator.translateLayers(layers, language);
+ }
+
+ renderer.load(rendererStyle);
createCallback(null, renderer);
};
return new advancedPool.Pool({
min: min,
max: max,
- create: createRenderer.bind(null, ratio),
+ create: createRenderer.bind(null),
destroy: (renderer) => {
renderer.release();
},
@@ -1471,13 +1521,18 @@ export const serve_rendered = {
const j = Math.min(maxPoolSizes.length - 1, s - 1);
const minPoolSize = minPoolSizes[i];
const maxPoolSize = Math.max(minPoolSize, maxPoolSizes[j]);
- map.renderers[s] = createPool(s, 'tile', minPoolSize, maxPoolSize);
- map.renderers_static[s] = createPool(
- s,
- 'static',
- minPoolSize,
- maxPoolSize,
- );
+ const languages = new Set([null, ...(options.languages || [])]);
+
+ map.renderers[s] = createPool(s, 'tile', minPoolSize, maxPoolSize, null);
+ for (const language of languages) {
+ map.renderers_static[`${s}${language}`] = createPool(
+ s,
+ 'static',
+ language ? 1 : minPoolSize,
+ language ? 1 : maxPoolSize,
+ language,
+ );
+ }
}
});
diff --git a/src/server.js b/src/server.js
index 88d026c..94872e3 100644
--- a/src/server.js
+++ b/src/server.js
@@ -83,6 +83,7 @@ function start(opts) {
}
const options = config.options || {};
+ options.verbose = opts.verbose;
const paths = options.paths || {};
options.paths = paths;
paths.root = path.resolve(
@@ -441,6 +442,10 @@ function start(opts) {
style.thumbnail = `${center[2]}/${Math.floor(
centerPx[0] / 256,
)}/${Math.floor(centerPx[1] / 256)}.png`;
+
+ style.static_center = `${center[0].toFixed(5)},${center[1].toFixed(
+ 5,
+ )},${center[2]}`;
}
style.xyz_link = getTileUrls(
diff --git a/src/translate_layers.js b/src/translate_layers.js
new file mode 100644
index 0000000..facbe39
--- /dev/null
+++ b/src/translate_layers.js
@@ -0,0 +1,186 @@
+/*
+
+File copied from https://github.com/maptiler/maptiler-sdk-js/blob/c5c7a343dcf4083a5eca75ede319991b80fcb652/src/Map.ts#L579
+
+BSD 3-Clause License
+
+Copyright (c) 2022, MapTiler
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+*/
+
+// prettier-ignore
+export default function translateLayers(layers, language) {
+ /* eslint-disable */
+
+ // detects pattern like "{name:somelanguage}" with loose spacing
+ const strLanguageRegex = /^\s*{\s*name\s*(:\s*(\S*))?\s*}$/;
+
+ // detects pattern like "name:somelanguage" with loose spacing
+ const strLanguageInArrayRegex = /^\s*name\s*(:\s*(\S*))?\s*$/;
+
+ // for string based bilingual lang such as "{name:latin} {name:nonlatin}" or "{name:latin} {name}"
+ const strBilingualRegex =
+ /^\s*{\s*name\s*(:\s*(\S*))?\s*}(\s*){\s*name\s*(:\s*(\S*))?\s*}$/;
+
+ // Regex to capture when there are more info, such as mountains elevation with unit m/ft
+ const strMoreInfoRegex = /^(.*)({\s*name\s*(:\s*(\S*))?\s*})(.*)$/;
+
+ const langStr = language ? `name:${language}` : "name"; // to handle local lang
+ const replacer = [
+ "case",
+ ["has", langStr],
+ ["get", langStr],
+ ["get", "name"],
+ ];
+
+ for (let i = 0; i < layers.length; i += 1) {
+ const layer = layers[i];
+ const layout = layer.layout;
+
+ if (!layout) {
+ continue;
+ }
+
+ if (!layout["text-field"]) {
+ continue;
+ }
+
+ const textFieldLayoutProp = this.getLayoutProperty(
+ layer.id,
+ "text-field"
+ );
+
+ // Note:
+ // The value of the 'text-field' property can take multiple shape;
+ // 1. can be an array with 'concat' on its first element (most likely means bilingual)
+ // 2. can be an array with 'get' on its first element (monolingual)
+ // 3. can be a string of shape '{name:latin}'
+ // 4. can be a string referencing another prop such as '{housenumber}' or '{ref}'
+ //
+ // The case 1, 2 and 3 will be updated while maintaining their original type and shape.
+ // The case 3 will not be updated
+
+ let regexMatch;
+
+ // This is case 1
+ if (
+ Array.isArray(textFieldLayoutProp) &&
+ textFieldLayoutProp.length >= 2 &&
+ textFieldLayoutProp[0].trim().toLowerCase() === "concat"
+ ) {
+ const newProp = textFieldLayoutProp.slice(); // newProp is Array
+ // The style could possibly have defined more than 2 concatenated language strings but we only want to edit the first
+ // The style could also define that there are more things being concatenated and not only languages
+
+ for (let j = 0; j < textFieldLayoutProp.length; j += 1) {
+ const elem = textFieldLayoutProp[j];
+
+ // we are looking for an elem of shape '{name:somelangage}' (string) of `["get", "name:somelanguage"]` (array)
+
+ // the entry of of shape '{name:somelangage}', possibly with loose spacing
+ if (
+ (typeof elem === "string" || elem instanceof String) &&
+ strLanguageRegex.exec(elem.toString())
+ ) {
+ newProp[j] = replacer;
+ break; // we just want to update the primary language
+ }
+ // the entry is of an array of shape `["get", "name:somelanguage"]`
+ else if (
+ Array.isArray(elem) &&
+ elem.length >= 2 &&
+ elem[0].trim().toLowerCase() === "get" &&
+ strLanguageInArrayRegex.exec(elem[1].toString())
+ ) {
+ newProp[j] = replacer;
+ break; // we just want to update the primary language
+ } else if (
+ Array.isArray(elem) &&
+ elem.length === 4 &&
+ elem[0].trim().toLowerCase() === "case"
+ ) {
+ newProp[j] = replacer;
+ break; // we just want to update the primary language
+ }
+ }
+
+ this.setLayoutProperty(layer.id, "text-field", newProp);
+ }
+
+ // This is case 2
+ else if (
+ Array.isArray(textFieldLayoutProp) &&
+ textFieldLayoutProp.length >= 2 &&
+ textFieldLayoutProp[0].trim().toLowerCase() === "get" &&
+ strLanguageInArrayRegex.exec(textFieldLayoutProp[1].toString())
+ ) {
+ const newProp = replacer;
+ this.setLayoutProperty(layer.id, "text-field", newProp);
+ }
+
+ // This is case 3
+ else if (
+ (typeof textFieldLayoutProp === "string" ||
+ textFieldLayoutProp instanceof String) &&
+ strLanguageRegex.exec(textFieldLayoutProp.toString())
+ ) {
+ const newProp = replacer;
+ this.setLayoutProperty(layer.id, "text-field", newProp);
+ } else if (
+ Array.isArray(textFieldLayoutProp) &&
+ textFieldLayoutProp.length === 4 &&
+ textFieldLayoutProp[0].trim().toLowerCase() === "case"
+ ) {
+ const newProp = replacer;
+ this.setLayoutProperty(layer.id, "text-field", newProp);
+ } else if (
+ (typeof textFieldLayoutProp === "string" ||
+ textFieldLayoutProp instanceof String) &&
+ (regexMatch = strBilingualRegex.exec(
+ textFieldLayoutProp.toString()
+ )) !== null
+ ) {
+ const newProp = `{${langStr}}${regexMatch[3]}{name${
+ regexMatch[4] || ""
+ }}`;
+ this.setLayoutProperty(layer.id, "text-field", newProp);
+ } else if (
+ (typeof textFieldLayoutProp === "string" ||
+ textFieldLayoutProp instanceof String) &&
+ (regexMatch = strMoreInfoRegex.exec(
+ textFieldLayoutProp.toString()
+ )) !== null
+ ) {
+ const newProp = `${regexMatch[1]}{${langStr}}${regexMatch[5]}`;
+ this.setLayoutProperty(layer.id, "text-field", newProp);
+ }
+ }
+
+ /* eslint-enable */
+}
diff --git a/test/static.js b/test/static.js
index 302becb..6be8423 100644
--- a/test/static.js
+++ b/test/static.js
@@ -128,6 +128,18 @@ describe('Static endpoints', function () {
testStatic(prefix, '-280,-80,0,80/280x160', 'png', 200);
});
+
+ describe('ignores an unknown language gracefully', function () {
+ testStatic(
+ prefix,
+ '-180,-90,180,90/20x20',
+ 'png',
+ 200,
+ 2,
+ undefined,
+ '?language=kr',
+ );
+ });
});
describe('invalid requests return 4xx', function () {