Add support for multi-sprites in styles (#1232)
* fix: allow sprite array in styles Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: add 'sprite/' to generated path So it doesn't conflict with style json url Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: allow old sprite endpoints to work Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: lint Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * docs: add multi-sprite name to endpoint Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: duplicate styleJSON.sprite Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: try to fix some codeql errors Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: try to fix some codeql errors Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: try to fix some codeql errors Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: try to fix some codeql errors Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: try to fix some codeql errors Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: lint Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: ci sprite issue Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: remove console.log's Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> * fix: use sprite id instead of name Signed-off-by: acalcutt <acalcutt@techidiots.net> * fix: add suggestions Signed-off-by: acalcutt <acalcutt@techidiots.net> * fix: remove unneeded array check Signed-off-by: acalcutt <acalcutt@techidiots.net> * fix: lint Signed-off-by: acalcutt <acalcutt@techidiots.net> * fix: don't force multi-sprites in style Signed-off-by: acalcutt <acalcutt@techidiots.net> * fix: move fixUrl and allowedOptions to utils Signed-off-by: acalcutt <acalcutt@techidiots.net> * fix: add test for default sprite Signed-off-by: acalcutt <acalcutt@techidiots.net> * fix: lint Signed-off-by: acalcutt <acalcutt@techidiots.net> --------- Signed-off-by: Andrew Calcutt <acalcutt@techidiots.net> Signed-off-by: acalcutt <acalcutt@techidiots.net>
This commit is contained in:
parent
3ef69c904f
commit
66445e8653
5 changed files with 141 additions and 60 deletions
|
@ -8,7 +8,7 @@ Styles
|
||||||
======
|
======
|
||||||
* Styles are served at ``/styles/{id}/style.json`` (+ array at ``/styles.json``)
|
* Styles are served at ``/styles/{id}/style.json`` (+ array at ``/styles.json``)
|
||||||
|
|
||||||
* Sprites at ``/styles/{id}/sprite[@2x].{format}``
|
* Sprites at ``/styles/{id}/sprite[/spriteID][@2x].{format}``
|
||||||
* Fonts at ``/fonts/{fontstack}/{start}-{end}.pbf``
|
* Fonts at ``/fonts/{fontstack}/{start}-{end}.pbf``
|
||||||
|
|
||||||
Rendered tiles
|
Rendered tiles
|
||||||
|
|
|
@ -46,7 +46,7 @@ import { renderOverlay, renderWatermark, renderAttribution } from './render.js';
|
||||||
const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)';
|
const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)';
|
||||||
const PATH_PATTERN =
|
const PATH_PATTERN =
|
||||||
/^((fill|stroke|width)\:[^\|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/;
|
/^((fill|stroke|width)\:[^\|]+\|)*(enc:.+|-?\d+(\.\d*)?,-?\d+(\.\d*)?(\|-?\d+(\.\d*)?,-?\d+(\.\d*)?)+)/;
|
||||||
const httpTester = /^(http(s)?:)?\/\//;
|
const httpTester = /^https?:\/\//i;
|
||||||
|
|
||||||
const mercator = new SphericalMercator();
|
const mercator = new SphericalMercator();
|
||||||
const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0;
|
const getScale = (scale) => (scale || '@1x').slice(1, 2) | 0;
|
||||||
|
@ -1045,16 +1045,27 @@ export const serve_rendered = {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styleJSON.sprite && !httpTester.test(styleJSON.sprite)) {
|
if (styleJSON.sprite) {
|
||||||
styleJSON.sprite =
|
if (!Array.isArray(styleJSON.sprite)) {
|
||||||
'sprites://' +
|
styleJSON.sprite = [{ id: 'default', url: styleJSON.sprite }];
|
||||||
styleJSON.sprite
|
}
|
||||||
.replace('{style}', path.basename(styleFile, '.json'))
|
styleJSON.sprite.forEach((spriteItem) => {
|
||||||
.replace(
|
if (!httpTester.test(spriteItem.url)) {
|
||||||
'{styleJsonFolder}',
|
spriteItem.url =
|
||||||
path.relative(options.paths.sprites, path.dirname(styleJSONPath)),
|
'sprites://' +
|
||||||
);
|
spriteItem.url
|
||||||
|
.replace('{style}', path.basename(styleFile, '.json'))
|
||||||
|
.replace(
|
||||||
|
'{styleJsonFolder}',
|
||||||
|
path.relative(
|
||||||
|
options.paths.sprites,
|
||||||
|
path.dirname(styleJSONPath),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) {
|
if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) {
|
||||||
styleJSON.glyphs = `fonts://${styleJSON.glyphs}`;
|
styleJSON.glyphs = `fonts://${styleJSON.glyphs}`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,24 +7,11 @@ import clone from 'clone';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec';
|
import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec';
|
||||||
|
|
||||||
import { getPublicUrl } from './utils.js';
|
import { fixUrl, allowedOptions } from './utils.js';
|
||||||
|
|
||||||
const httpTester = /^(http(s)?:)?\/\//;
|
const httpTester = /^https?:\/\//i;
|
||||||
|
const allowedSpriteScales = allowedOptions(['', '@2x', '@3x']);
|
||||||
const fixUrl = (req, url, publicUrl) => {
|
const allowedSpriteFormats = allowedOptions(['png', 'json']);
|
||||||
if (!url || typeof url !== 'string' || url.indexOf('local://') !== 0) {
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
const queryParams = [];
|
|
||||||
if (req.query.key) {
|
|
||||||
queryParams.unshift(`key=${encodeURIComponent(req.query.key)}`);
|
|
||||||
}
|
|
||||||
let query = '';
|
|
||||||
if (queryParams.length) {
|
|
||||||
query = `?${queryParams.join('&')}`;
|
|
||||||
}
|
|
||||||
return url.replace('local://', getPublicUrl(publicUrl, req)) + query;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const serve_style = {
|
export const serve_style = {
|
||||||
init: (options, repo) => {
|
init: (options, repo) => {
|
||||||
|
@ -42,7 +29,13 @@ export const serve_style = {
|
||||||
}
|
}
|
||||||
// mapbox-gl-js viewer cannot handle sprite urls with query
|
// mapbox-gl-js viewer cannot handle sprite urls with query
|
||||||
if (styleJSON_.sprite) {
|
if (styleJSON_.sprite) {
|
||||||
styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl);
|
if (Array.isArray(styleJSON_.sprite)) {
|
||||||
|
styleJSON_.sprite.forEach((spriteItem) => {
|
||||||
|
spriteItem.url = fixUrl(req, spriteItem.url, item.publicUrl);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
styleJSON_.sprite = fixUrl(req, styleJSON_.sprite, item.publicUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (styleJSON_.glyphs) {
|
if (styleJSON_.glyphs) {
|
||||||
styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl);
|
styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl);
|
||||||
|
@ -50,25 +43,39 @@ export const serve_style = {
|
||||||
return res.send(styleJSON_);
|
return res.send(styleJSON_);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/:id/sprite:scale(@[23]x)?.:format([\\w]+)', (req, res, next) => {
|
app.get(
|
||||||
const item = repo[req.params.id];
|
'/:id/sprite(/:spriteID)?:scale(@[23]x)?.:format([\\w]+)',
|
||||||
if (!item || !item.spritePath) {
|
(req, res, next) => {
|
||||||
return res.sendStatus(404);
|
const { spriteID = 'default', id } = req.params;
|
||||||
}
|
const scale = allowedSpriteScales(req.params.scale) || '';
|
||||||
const scale = req.params.scale;
|
const format = allowedSpriteFormats(req.params.format);
|
||||||
const format = req.params.format;
|
|
||||||
const filename = `${item.spritePath + (scale || '')}.${format}`;
|
if (format) {
|
||||||
return fs.readFile(filename, (err, data) => {
|
const item = repo[id];
|
||||||
if (err) {
|
const sprite = item.spritePaths.find(
|
||||||
console.log('Sprite load error:', filename);
|
(sprite) => sprite.id === spriteID,
|
||||||
return res.sendStatus(404);
|
);
|
||||||
|
if (sprite) {
|
||||||
|
const filename = `${sprite.path + scale}.${format}`;
|
||||||
|
return fs.readFile(filename, (err, data) => {
|
||||||
|
if (err) {
|
||||||
|
console.log('Sprite load error:', filename);
|
||||||
|
return res.sendStatus(404);
|
||||||
|
} else {
|
||||||
|
if (format === 'json')
|
||||||
|
res.header('Content-type', 'application/json');
|
||||||
|
if (format === 'png') res.header('Content-type', 'image/png');
|
||||||
|
return res.send(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return res.status(400).send('Bad Sprite ID or Scale');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (format === 'json') res.header('Content-type', 'application/json');
|
return res.status(400).send('Bad Sprite Format');
|
||||||
if (format === 'png') res.header('Content-type', 'image/png');
|
|
||||||
return res.send(data);
|
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
},
|
},
|
||||||
|
@ -135,27 +142,48 @@ export const serve_style = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let spritePath;
|
let spritePaths = [];
|
||||||
|
if (styleJSON.sprite) {
|
||||||
if (styleJSON.sprite && !httpTester.test(styleJSON.sprite)) {
|
if (!Array.isArray(styleJSON.sprite)) {
|
||||||
spritePath = path.join(
|
if (!httpTester.test(styleJSON.sprite)) {
|
||||||
options.paths.sprites,
|
let spritePath = path.join(
|
||||||
styleJSON.sprite
|
options.paths.sprites,
|
||||||
.replace('{style}', path.basename(styleFile, '.json'))
|
styleJSON.sprite
|
||||||
.replace(
|
.replace('{style}', path.basename(styleFile, '.json'))
|
||||||
'{styleJsonFolder}',
|
.replace(
|
||||||
path.relative(options.paths.sprites, path.dirname(styleFile)),
|
'{styleJsonFolder}',
|
||||||
),
|
path.relative(options.paths.sprites, path.dirname(styleFile)),
|
||||||
);
|
),
|
||||||
styleJSON.sprite = `local://styles/${id}/sprite`;
|
);
|
||||||
|
styleJSON.sprite = `local://styles/${id}/sprite`;
|
||||||
|
spritePaths.push({ id: 'default', path: spritePath });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let spriteItem of styleJSON.sprite) {
|
||||||
|
if (!httpTester.test(spriteItem.url)) {
|
||||||
|
let spritePath = path.join(
|
||||||
|
options.paths.sprites,
|
||||||
|
spriteItem.url
|
||||||
|
.replace('{style}', path.basename(styleFile, '.json'))
|
||||||
|
.replace(
|
||||||
|
'{styleJsonFolder}',
|
||||||
|
path.relative(options.paths.sprites, path.dirname(styleFile)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
spriteItem.url = `local://styles/${id}/sprite/` + spriteItem.id;
|
||||||
|
spritePaths.push({ id: spriteItem.id, path: spritePath });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) {
|
if (styleJSON.glyphs && !httpTester.test(styleJSON.glyphs)) {
|
||||||
styleJSON.glyphs = 'local://fonts/{fontstack}/{range}.pbf';
|
styleJSON.glyphs = 'local://fonts/{fontstack}/{range}.pbf';
|
||||||
}
|
}
|
||||||
|
|
||||||
repo[id] = {
|
repo[id] = {
|
||||||
styleJSON,
|
styleJSON,
|
||||||
spritePath,
|
spritePaths,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
name: styleJSON.name,
|
name: styleJSON.name,
|
||||||
};
|
};
|
||||||
|
|
32
src/utils.js
32
src/utils.js
|
@ -6,6 +6,38 @@ import fs, { existsSync } from 'node:fs';
|
||||||
import clone from 'clone';
|
import clone from 'clone';
|
||||||
import glyphCompose from '@mapbox/glyph-pbf-composite';
|
import glyphCompose from '@mapbox/glyph-pbf-composite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restrict user input to an allowed set of options.
|
||||||
|
* @param opts
|
||||||
|
* @param root0
|
||||||
|
* @param root0.defaultValue
|
||||||
|
*/
|
||||||
|
export function allowedOptions(opts, { defaultValue } = {}) {
|
||||||
|
const values = Object.fromEntries(opts.map((key) => [key, key]));
|
||||||
|
return (value) => values[value] || defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace local:// urls with public http(s):// urls
|
||||||
|
* @param req
|
||||||
|
* @param url
|
||||||
|
* @param publicUrl
|
||||||
|
*/
|
||||||
|
export function fixUrl(req, url, publicUrl) {
|
||||||
|
if (!url || typeof url !== 'string' || url.indexOf('local://') !== 0) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
const queryParams = [];
|
||||||
|
if (req.query.key) {
|
||||||
|
queryParams.unshift(`key=${encodeURIComponent(req.query.key)}`);
|
||||||
|
}
|
||||||
|
let query = '';
|
||||||
|
if (queryParams.length) {
|
||||||
|
query = `?${queryParams.join('&')}`;
|
||||||
|
}
|
||||||
|
return url.replace('local://', getPublicUrl(publicUrl, req)) + query;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate new URL object
|
* Generate new URL object
|
||||||
* @param req
|
* @param req
|
||||||
|
|
|
@ -41,6 +41,16 @@ describe('Styles', function () {
|
||||||
testIs('/styles/' + prefix + '/sprite.png', /image\/png/);
|
testIs('/styles/' + prefix + '/sprite.png', /image\/png/);
|
||||||
testIs('/styles/' + prefix + '/sprite@2x.png', /image\/png/);
|
testIs('/styles/' + prefix + '/sprite@2x.png', /image\/png/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('/styles/' + prefix + '/sprite/default[@2x].{format}', function () {
|
||||||
|
testIs('/styles/' + prefix + '/sprite/default.json', /application\/json/);
|
||||||
|
testIs(
|
||||||
|
'/styles/' + prefix + '/sprite/default@2x.json',
|
||||||
|
/application\/json/,
|
||||||
|
);
|
||||||
|
testIs('/styles/' + prefix + '/sprite/default.png', /image\/png/);
|
||||||
|
testIs('/styles/' + prefix + '/sprite/default@2x.png', /image\/png/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Fonts', function () {
|
describe('Fonts', function () {
|
||||||
|
|
Loading…
Reference in a new issue