feat: option to translate labels for static maps
Signed-off-by: Martin d'Allens <martin.dallens@liberty-rider.com>
This commit is contained in:
parent
1b97491739
commit
57931c889d
8 changed files with 294 additions and 24 deletions
|
|
@ -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/.
|
||||
You can read full documentation of this project at https://tileserver.readthedocs.io/.
|
||||
|
|
|
|||
|
|
@ -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``
|
||||
==========
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@
|
|||
{{#if serving_rendered}}
|
||||
| <a href="{{public_url}}styles/{{@key}}/wmts.xml{{&../key_query}}">WMTS</a>
|
||||
{{/if}}
|
||||
{{#if serving_rendered}}
|
||||
| <a href="{{public_url}}styles/{{@key}}/static/{{static_center}}/500x400@2x.png{{&../key_query}}">Static</a>
|
||||
{{/if}}
|
||||
{{#if xyz_link}}
|
||||
| <a href="#" onclick="return toggle_xyz('xyz_style_{{@key}}');">XYZ</a>
|
||||
<input id="xyz_style_{{@key}}" type="text" value="{{&xyz_link}}" style="display:none;" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
186
src/translate_layers.js
Normal file
186
src/translate_layers.js
Normal 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 */
|
||||
}
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue