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:
Andrew Calcutt 2024-04-23 14:51:37 -04:00 committed by GitHub
parent 3ef69c904f
commit 66445e8653
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 141 additions and 60 deletions

View file

@ -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

View file

@ -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}`;
} }

View file

@ -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,
}; };

View file

@ -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

View file

@ -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 () {