feat: option to translate labels for static maps

Signed-off-by: Martin d'Allens <martin.dallens@liberty-rider.com>
This commit is contained in:
Martin d'Allens 2023-09-28 16:11:10 +02:00
parent 1b97491739
commit 57931c889d
8 changed files with 294 additions and 24 deletions

View file

@ -28,7 +28,7 @@ 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. [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 ## Documentation

View file

@ -33,6 +33,7 @@ Example:
"serveAllStyles": false, "serveAllStyles": false,
"serveStaticMaps": true, "serveStaticMaps": true,
"allowRemoteMarkerIcons": true, "allowRemoteMarkerIcons": true,
"languages": ["en", "fr", "it"],
"tileMargin": 0 "tileMargin": 0
}, },
"styles": { "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! 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. 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`` ``styles``
========== ==========

View file

@ -27,13 +27,13 @@ WMTS Capabilities
Static images 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) * Center and zoom level: ``/styles/{id}/static/{lon},{lat},{zoom}[@{bearing}[,{pitch}]]/{width}x{height}[@2x].{format}``
* ``/styles/{id}/static/{minx},{miny},{maxx},{maxy}/{width}x{height}[@2x].{format}`` (area-based) * Bounds, e.g. latitude/longitude of corners: ``/styles/{id}/static/{minx},{miny},{maxx},{maxy}/{width}x{height}[@2x].{format}``
* ``/styles/{id}/static/auto/{width}x{height}[@2x].{format}`` (autofit path -- see below) * 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*)))`` * ``path`` - ``((fill|stroke|width)\:[^\|]+\|)*((enc:.+)|((-?\d+\.?\d*,-?\d+\.?\d*\|)+(-?\d+\.?\d*,-?\d+\.?\d*)))``
@ -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" * 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) * ``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. * You can also use (experimental) ``/styles/{id}/static/raw/...`` endpoints with raw spherical mercator coordinates (EPSG:3857) instead of WGS84.

View file

@ -43,6 +43,9 @@
{{#if serving_rendered}} {{#if serving_rendered}}
| <a href="{{public_url}}styles/{{@key}}/wmts.xml{{&../key_query}}">WMTS</a> | <a href="{{public_url}}styles/{{@key}}/wmts.xml{{&../key_query}}">WMTS</a>
{{/if}} {{/if}}
{{#if serving_rendered}}
| <a href="{{public_url}}styles/{{@key}}/static/{{static_center}}/500x400@2x.png{{&../key_query}}">Static</a>
{{/if}}
{{#if xyz_link}} {{#if xyz_link}}
| <a href="#" onclick="return toggle_xyz('xyz_style_{{@key}}');">XYZ</a> | <a href="#" onclick="return toggle_xyz('xyz_style_{{@key}}');">XYZ</a>
<input id="xyz_style_{{@key}}" type="text" value="{{&xyz_link}}" style="display:none;" /> <input id="xyz_style_{{@key}}" type="text" value="{{&xyz_link}}" style="display:none;" />

View file

@ -19,6 +19,7 @@ import polyline from '@mapbox/polyline';
import proj4 from 'proj4'; import proj4 from 'proj4';
import request from 'request'; import request from 'request';
import { getFontsPbf, getTileUrls, fixTileJSONCenter } from './utils.js'; import { getFontsPbf, getTileUrls, fixTileJSONCenter } from './utils.js';
import translateLayers from './translate_layers.js';
const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)'; const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)';
const PATH_PATTERN = const PATH_PATTERN =
@ -202,6 +203,19 @@ const extractPathsFromQuery = (query, transformer) => {
return paths; 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 * Parses marker options provided via query and sets corresponding attributes
* on marker object. * 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} ctx Canvas context object.
* @param {object} marker Marker object parsed by extractMarkersFromQuery. * @param {object} marker Marker object parsed by extractMarkersFromQuery.
* @param {number} z Map zoom level. * @param {number} z Map zoom level.
@ -646,6 +660,7 @@ export const serve_rendered = {
next, next,
opt_overlay, opt_overlay,
opt_mode = 'tile', opt_mode = 'tile',
language = null,
) => { ) => {
if ( if (
Math.abs(lon) > 180 || Math.abs(lon) > 180 ||
@ -677,7 +692,7 @@ export const serve_rendered = {
if (opt_mode === 'tile' && tileMargin === 0) { if (opt_mode === 'tile' && tileMargin === 0) {
pool = item.map.renderers[scale]; pool = item.map.renderers[scale];
} else { } else {
pool = item.map.renderers_static[scale]; pool = item.map.renderers_static[`${scale}${language}`];
} }
pool.acquire((err, renderer) => { pool.acquire((err, renderer) => {
const mlglZ = Math.max(0, z - 1); const mlglZ = Math.max(0, z - 1);
@ -712,6 +727,7 @@ export const serve_rendered = {
// Fix semi-transparent outlines on raw, premultiplied input // Fix semi-transparent outlines on raw, premultiplied input
// https://github.com/maptiler/tileserver-gl/issues/350#issuecomment-477857040 // 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) { for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]; const alpha = data[i + 3];
const norm = alpha / 255; const norm = alpha / 255;
@ -901,6 +917,7 @@ export const serve_rendered = {
} }
const paths = extractPathsFromQuery(req.query, transformer); const paths = extractPathsFromQuery(req.query, transformer);
const language = extractLanguageFromQuery(req.query, options);
const markers = extractMarkersFromQuery( const markers = extractMarkersFromQuery(
req.query, req.query,
options, options,
@ -935,6 +952,7 @@ export const serve_rendered = {
next, next,
overlay, overlay,
'static', 'static',
language,
); );
} catch (e) { } catch (e) {
next(e); next(e);
@ -983,6 +1001,7 @@ export const serve_rendered = {
const pitch = 0; const pitch = 0;
const paths = extractPathsFromQuery(req.query, transformer); const paths = extractPathsFromQuery(req.query, transformer);
const language = extractLanguageFromQuery(req.query, options);
const markers = extractMarkersFromQuery( const markers = extractMarkersFromQuery(
req.query, req.query,
options, options,
@ -1016,6 +1035,7 @@ export const serve_rendered = {
next, next,
overlay, overlay,
'static', 'static',
language,
); );
} catch (e) { } catch (e) {
next(e); next(e);
@ -1077,6 +1097,7 @@ export const serve_rendered = {
: item.dataProjWGStoInternalWGS; : item.dataProjWGStoInternalWGS;
const paths = extractPathsFromQuery(req.query, transformer); const paths = extractPathsFromQuery(req.query, transformer);
const language = extractLanguageFromQuery(req.query, options);
const markers = extractMarkersFromQuery( const markers = extractMarkersFromQuery(
req.query, req.query,
options, options,
@ -1150,6 +1171,7 @@ export const serve_rendered = {
next, next,
overlay, overlay,
'static', 'static',
language,
); );
} catch (e) { } catch (e) {
next(e); next(e);
@ -1184,14 +1206,16 @@ export const serve_rendered = {
}; };
let styleJSON; let styleJSON;
const createPool = (ratio, mode, min, max) => { const createPool = (scale, mode, min, max, language) => {
const createRenderer = (ratio, createCallback) => { const createRenderer = (createCallback) => {
const renderer = new mlgl.Map({ const renderer = new mlgl.Map({
mode: mode, mode: mode,
ratio: ratio, ratio: scale,
request: (req, callback) => { request: (req, callback) => {
const protocol = req.url.split(':')[0]; const protocol = req.url.split(':')[0];
// console.log('Handling request:', req); if (options.verbose) {
console.log('[VERBOSE] Handling request:', req);
}
if (protocol === 'sprites') { if (protocol === 'sprites') {
const dir = options.paths[protocol]; const dir = options.paths[protocol];
const file = unescape(req.url).substring(protocol.length + 3); 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 extension = path.extname(parts.pathname).toLowerCase();
const format = extensionToFormat[extension] || ''; const format = extensionToFormat[extension] || '';
if (err || res.statusCode < 200 || res.statusCode >= 300) { 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); createEmptyResponse(format, '', callback);
return; 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); createCallback(null, renderer);
}; };
return new advancedPool.Pool({ return new advancedPool.Pool({
min: min, min: min,
max: max, max: max,
create: createRenderer.bind(null, ratio), create: createRenderer.bind(null),
destroy: (renderer) => { destroy: (renderer) => {
renderer.release(); renderer.release();
}, },
@ -1471,14 +1521,19 @@ export const serve_rendered = {
const j = Math.min(maxPoolSizes.length - 1, s - 1); const j = Math.min(maxPoolSizes.length - 1, s - 1);
const minPoolSize = minPoolSizes[i]; const minPoolSize = minPoolSizes[i];
const maxPoolSize = Math.max(minPoolSize, maxPoolSizes[j]); const maxPoolSize = Math.max(minPoolSize, maxPoolSizes[j]);
map.renderers[s] = createPool(s, 'tile', minPoolSize, maxPoolSize); const languages = new Set([null, ...(options.languages || [])]);
map.renderers_static[s] = createPool(
map.renderers[s] = createPool(s, 'tile', minPoolSize, maxPoolSize, null);
for (const language of languages) {
map.renderers_static[`${s}${language}`] = createPool(
s, s,
'static', 'static',
minPoolSize, language ? 1 : minPoolSize,
maxPoolSize, language ? 1 : maxPoolSize,
language,
); );
} }
}
}); });
return Promise.all([renderersReadyPromise]); return Promise.all([renderersReadyPromise]);

View file

@ -83,6 +83,7 @@ function start(opts) {
} }
const options = config.options || {}; const options = config.options || {};
options.verbose = opts.verbose;
const paths = options.paths || {}; const paths = options.paths || {};
options.paths = paths; options.paths = paths;
paths.root = path.resolve( paths.root = path.resolve(
@ -441,6 +442,10 @@ function start(opts) {
style.thumbnail = `${center[2]}/${Math.floor( style.thumbnail = `${center[2]}/${Math.floor(
centerPx[0] / 256, centerPx[0] / 256,
)}/${Math.floor(centerPx[1] / 256)}.png`; )}/${Math.floor(centerPx[1] / 256)}.png`;
style.static_center = `${center[0].toFixed(5)},${center[1].toFixed(
5,
)},${center[2]}`;
} }
style.xyz_link = getTileUrls( style.xyz_link = getTileUrls(

186
src/translate_layers.js Normal file
View file

@ -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 */
}

View file

@ -128,6 +128,18 @@ describe('Static endpoints', function () {
testStatic(prefix, '-280,-80,0,80/280x160', 'png', 200); 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 () { describe('invalid requests return 4xx', function () {