Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for multi-sprites in styles #1232

Merged
merged 22 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/endpoints.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Styles
======
* 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``

Rendered tiles
Expand Down
31 changes: 21 additions & 10 deletions src/serve_rendered.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import { renderOverlay, renderWatermark, renderAttribution } from './render.js';
const FLOAT_PATTERN = '[+-]?(?:\\d+|\\d+.?\\d+)';
const PATH_PATTERN =
/^((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 getScale = (scale) => (scale || '@1x').slice(1, 2) | 0;
Expand Down Expand Up @@ -1045,16 +1045,27 @@ export const serve_rendered = {
return false;
}

if (styleJSON.sprite && !httpTester.test(styleJSON.sprite)) {
styleJSON.sprite =
'sprites://' +
styleJSON.sprite
.replace('{style}', path.basename(styleFile, '.json'))
.replace(
'{styleJsonFolder}',
path.relative(options.paths.sprites, path.dirname(styleJSONPath)),
);
if (styleJSON.sprite) {
if (!Array.isArray(styleJSON.sprite)) {
styleJSON.sprite = [{ id: 'default', url: styleJSON.sprite }];
}
styleJSON.sprite.forEach((spriteItem) => {
if (!httpTester.test(spriteItem.url)) {
spriteItem.url =
'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)) {
styleJSON.glyphs = `fonts://${styleJSON.glyphs}`;
}
Expand Down
128 changes: 78 additions & 50 deletions src/serve_style.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,11 @@ import clone from 'clone';
import express from 'express';
import { validateStyleMin } from '@maplibre/maplibre-gl-style-spec';

import { getPublicUrl } from './utils.js';

const httpTester = /^(http(s)?:)?\/\//;

const 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;
};
import { fixUrl, allowedOptions } from './utils.js';

const httpTester = /^https?:\/\//i;
const allowedSpriteScales = allowedOptions(['', '@2x', '@3x']);
const allowedSpriteFormats = allowedOptions(['png', 'json']);

export const serve_style = {
init: (options, repo) => {
Expand All @@ -42,33 +29,53 @@ export const serve_style = {
}
// mapbox-gl-js viewer cannot handle sprite urls with query
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) {
styleJSON_.glyphs = fixUrl(req, styleJSON_.glyphs, item.publicUrl);
}
return res.send(styleJSON_);
});

app.get('/:id/sprite:scale(@[23]x)?.:format([\\w]+)', (req, res, next) => {
const item = repo[req.params.id];
if (!item || !item.spritePath) {
return res.sendStatus(404);
}
const scale = req.params.scale;
const format = req.params.format;
const filename = `${item.spritePath + (scale || '')}.${format}`;
return fs.readFile(filename, (err, data) => {
if (err) {
console.log('Sprite load error:', filename);
return res.sendStatus(404);
app.get(
'/:id/sprite(/:spriteID)?:scale(@[23]x)?.:format([\\w]+)',
(req, res, next) => {
const { spriteID = 'default', id } = req.params;
const scale = allowedSpriteScales(req.params.scale) || '';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super minor thing, you can move the || '' in to make it the default:

Suggested change
const scale = allowedSpriteScales(req.params.scale) || '';
const scale = allowedSpriteScales(req.params.scale, { defaultValue: '' });

Copy link
Collaborator Author

@acalcutt acalcutt Apr 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when I try this, scale ends up 'undefined' if no scale is set, so it ends up putting that into the file name. I see this at the console

Sprite load error: C:\Users\andrew.EIRI\Desktop\test_data\styles\klokantech-basic\spriteundefined.png

const format = allowedSpriteFormats(req.params.format);

if (format) {
const item = repo[id];
const sprite = item.spritePaths.find(
(sprite) => sprite.id === spriteID,
);
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 {
if (format === 'json') res.header('Content-type', 'application/json');
if (format === 'png') res.header('Content-type', 'image/png');
return res.send(data);
return res.status(400).send('Bad Sprite Format');
}
});
});
},
);

return app;
},
Expand Down Expand Up @@ -135,27 +142,48 @@ export const serve_style = {
}
}

let spritePath;

if (styleJSON.sprite && !httpTester.test(styleJSON.sprite)) {
spritePath = path.join(
options.paths.sprites,
styleJSON.sprite
.replace('{style}', path.basename(styleFile, '.json'))
.replace(
'{styleJsonFolder}',
path.relative(options.paths.sprites, path.dirname(styleFile)),
),
);
styleJSON.sprite = `local://styles/${id}/sprite`;
let spritePaths = [];
if (styleJSON.sprite) {
if (!Array.isArray(styleJSON.sprite)) {
if (!httpTester.test(styleJSON.sprite)) {
let spritePath = path.join(
options.paths.sprites,
styleJSON.sprite
.replace('{style}', path.basename(styleFile, '.json'))
.replace(
'{styleJsonFolder}',
path.relative(options.paths.sprites, path.dirname(styleFile)),
),
);
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)) {
styleJSON.glyphs = 'local://fonts/{fontstack}/{range}.pbf';
}

repo[id] = {
styleJSON,
spritePath,
spritePaths,
publicUrl,
name: styleJSON.name,
};
Expand Down
32 changes: 32 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ import fs, { existsSync } from 'node:fs';
import clone from 'clone';
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
* @param req
Expand Down
10 changes: 10 additions & 0 deletions test/style.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ describe('Styles', function () {
testIs('/styles/' + prefix + '/sprite.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 () {
Expand Down
Loading