tileserver-gl-light 5.5.0-pre.1 → 5.5.0-pre.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +51 -34
  2. package/docs/config.rst +52 -11
  3. package/docs/endpoints.rst +12 -2
  4. package/docs/installation.rst +6 -6
  5. package/docs/usage.rst +26 -0
  6. package/package.json +14 -14
  7. package/public/resources/elevation-control.js +92 -21
  8. package/public/resources/maplibre-gl-inspect.js +2827 -2770
  9. package/public/resources/maplibre-gl-inspect.js.map +1 -1
  10. package/public/resources/maplibre-gl.css +1 -1
  11. package/public/resources/maplibre-gl.js +4 -4
  12. package/public/resources/maplibre-gl.js.map +1 -1
  13. package/src/main.js +31 -20
  14. package/src/pmtiles_adapter.js +104 -45
  15. package/src/promises.js +1 -1
  16. package/src/render.js +270 -93
  17. package/src/serve_data.js +266 -90
  18. package/src/serve_font.js +2 -2
  19. package/src/serve_light.js +2 -4
  20. package/src/serve_rendered.js +445 -236
  21. package/src/serve_style.js +29 -8
  22. package/src/server.js +115 -60
  23. package/src/utils.js +47 -20
  24. package/test/elevation.js +513 -0
  25. package/test/fixtures/visual/encoded-path-auto.png +0 -0
  26. package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
  27. package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
  28. package/test/fixtures/visual/path-auto.png +0 -0
  29. package/test/fixtures/visual/static-bbox.png +0 -0
  30. package/test/fixtures/visual/static-bearing-pitch.png +0 -0
  31. package/test/fixtures/visual/static-bearing.png +0 -0
  32. package/test/fixtures/visual/static-border-global.png +0 -0
  33. package/test/fixtures/visual/static-lat-lng.png +0 -0
  34. package/test/fixtures/visual/static-markers.png +0 -0
  35. package/test/fixtures/visual/static-multiple-paths.png +0 -0
  36. package/test/fixtures/visual/static-path-border-isolated.png +0 -0
  37. package/test/fixtures/visual/static-path-border-stroke.png +0 -0
  38. package/test/fixtures/visual/static-path-latlng.png +0 -0
  39. package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
  40. package/test/static_images.js +241 -0
  41. package/test/tiles_data.js +1 -1
  42. package/test/utils/create_terrain_mbtiles.js +124 -0
@@ -35,7 +35,7 @@ export const serve_style = {
35
35
  */
36
36
  app.get('/:id/style.json', (req, res, next) => {
37
37
  const { id } = req.params;
38
- if (verbose) {
38
+ if (verbose >= 1) {
39
39
  console.log(
40
40
  'Handling style request for: /styles/%s/style.json',
41
41
  String(id).replace(/\n|\r/g, ''),
@@ -95,7 +95,7 @@ export const serve_style = {
95
95
  const sanitizedFormat = format
96
96
  ? '.' + String(format).replace(/\n|\r/g, '')
97
97
  : '';
98
- if (verbose) {
98
+ if (verbose >= 1) {
99
99
  console.log(
100
100
  `Handling sprite request for: /styles/%s/sprite/%s%s%s`,
101
101
  sanitizedId,
@@ -108,7 +108,7 @@ export const serve_style = {
108
108
  const item = repo[id];
109
109
  const validatedFormat = allowedSpriteFormats(format);
110
110
  if (!item || !validatedFormat) {
111
- if (verbose)
111
+ if (verbose >= 1)
112
112
  console.error(
113
113
  `Sprite item or format not found for: /styles/%s/sprite/%s%s%s`,
114
114
  sanitizedId,
@@ -123,7 +123,7 @@ export const serve_style = {
123
123
  );
124
124
  const spriteScale = allowedSpriteScales(scale);
125
125
  if (!sprite || spriteScale === null) {
126
- if (verbose)
126
+ if (verbose >= 1)
127
127
  console.error(
128
128
  `Bad Sprite ID or Scale for: /styles/%s/sprite/%s%s%s`,
129
129
  sanitizedId,
@@ -147,7 +147,7 @@ export const serve_style = {
147
147
 
148
148
  const sanitizedSpritePath = sprite.path.replace(/^(\.\.\/)+/, '');
149
149
  const filename = `${sanitizedSpritePath}${spriteScale}.${validatedFormat}`;
150
- if (verbose) console.log(`Loading sprite from: %s`, filename);
150
+ if (verbose >= 1) console.log(`Loading sprite from: %s`, filename);
151
151
  try {
152
152
  const data = await readFile(filename);
153
153
 
@@ -156,7 +156,7 @@ export const serve_style = {
156
156
  } else if (validatedFormat === 'png') {
157
157
  res.header('Content-type', 'image/png');
158
158
  }
159
- if (verbose)
159
+ if (verbose >= 1)
160
160
  console.log(
161
161
  `Responding with sprite data for /styles/%s/sprite/%s%s%s`,
162
162
  sanitizedId,
@@ -167,7 +167,7 @@ export const serve_style = {
167
167
  res.set({ 'Last-Modified': item.lastModified });
168
168
  return res.send(data);
169
169
  } catch (err) {
170
- if (verbose) {
170
+ if (verbose >= 1) {
171
171
  console.error(
172
172
  'Sprite load error: %s, Error: %s',
173
173
  filename,
@@ -217,7 +217,28 @@ export const serve_style = {
217
217
  const styleFile = path.resolve(options.paths.styles, params.style);
218
218
  const styleJSON = clone(style);
219
219
 
220
- const validationErrors = validateStyleMin(styleJSON);
220
+ // Sanitize style for validation: remove non-spec properties (e.g., 'sparse')
221
+ // so that validateStyleMin doesn't reject valid styles containing our custom flags.
222
+ const styleForValidation = clone(styleJSON);
223
+ if (styleForValidation.sources) {
224
+ for (const name of Object.keys(styleForValidation.sources)) {
225
+ if (
226
+ // eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
227
+ styleForValidation.sources[name] &&
228
+ // eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
229
+ 'sparse' in styleForValidation.sources[name]
230
+ ) {
231
+ try {
232
+ // eslint-disable-next-line security/detect-object-injection -- name is from Object.keys of styleForValidation.sources
233
+ delete styleForValidation.sources[name].sparse;
234
+ } catch (_err) {
235
+ // ignore any deletion errors and continue validation
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ const validationErrors = validateStyleMin(styleForValidation);
221
242
  if (validationErrors.length > 0) {
222
243
  console.log(`The file "${params.style}" is not a valid style file:`);
223
244
  for (const err of validationErrors) {
package/src/server.js CHANGED
@@ -31,9 +31,9 @@ const packageJson = JSON.parse(
31
31
  );
32
32
  const isLight = packageJson.name.slice(-6) === '-light';
33
33
 
34
- const serve_rendered = (
35
- await import(`${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`)
36
- ).serve_rendered;
34
+ const { serve_rendered } = await import(
35
+ `${!isLight ? `./serve_rendered.js` : `./serve_light.js`}`
36
+ );
37
37
 
38
38
  /**
39
39
  * Starts the server.
@@ -60,8 +60,7 @@ async function start(opts) {
60
60
  app.use(
61
61
  morgan(logFormat, {
62
62
  stream: opts.logFile
63
- ? // eslint-disable-next-line security/detect-non-literal-fs-filename -- logFile is from CLI/config, admin-controlled
64
- fs.createWriteStream(opts.logFile, { flags: 'a' })
63
+ ? fs.createWriteStream(opts.logFile, { flags: 'a' })
65
64
  : process.stdout,
66
65
  skip: (req, res) =>
67
66
  opts.silent && (res.statusCode === 200 || res.statusCode === 304),
@@ -74,9 +73,8 @@ async function start(opts) {
74
73
  if (opts.configPath) {
75
74
  configPath = path.resolve(opts.configPath);
76
75
  try {
77
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- configPath is from CLI argument, expected behavior
78
76
  config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
79
- } catch (e) {
77
+ } catch {
80
78
  console.log('ERROR: Config file not found or invalid!');
81
79
  console.log(' See README.md for instructions and sample data.');
82
80
  process.exit(1);
@@ -109,8 +107,7 @@ async function start(opts) {
109
107
  const startupPromises = [];
110
108
 
111
109
  for (const type of Object.keys(paths)) {
112
- // eslint-disable-next-line security/detect-object-injection -- type is from Object.keys of paths config
113
- // eslint-disable-next-line security/detect-non-literal-fs-filename, security/detect-object-injection -- paths[type] constructed from validated config paths
110
+ // eslint-disable-next-line security/detect-object-injection -- paths[type] constructed from validated config paths
114
111
  if (!fs.existsSync(paths[type])) {
115
112
  console.error(
116
113
  // eslint-disable-next-line security/detect-object-injection -- type is from Object.keys of paths config
@@ -128,7 +125,7 @@ async function start(opts) {
128
125
  */
129
126
  async function getFiles(directory) {
130
127
  // Fetch all entries of the directory and attach type information
131
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- directory is constructed from validated config paths
128
+
132
129
  const dirEntries = await fs.promises.readdir(directory, {
133
130
  withFileTypes: true,
134
131
  });
@@ -157,12 +154,13 @@ async function start(opts) {
157
154
 
158
155
  if (options.dataDecorator) {
159
156
  try {
160
- // eslint-disable-next-line security/detect-non-literal-require -- dataDecorator path is from config file, admin-controlled
161
- options.dataDecoratorFunc = require(
162
- path.resolve(paths.root, options.dataDecorator),
163
- );
157
+ const dataDecoratorPath = path.resolve(paths.root, options.dataDecorator);
158
+
159
+ const module = await import(dataDecoratorPath);
160
+ options.dataDecoratorFunc = module.default;
164
161
  } catch (e) {
165
- // Silently fail if dataDecorator cannot be loaded
162
+ console.error(`Error loading data decorator: ${e}`);
163
+ // Intentionally don't set options.dataDecoratorFunc - let it remain undefined
166
164
  }
167
165
  }
168
166
 
@@ -204,12 +202,13 @@ async function start(opts) {
204
202
  styleJSON = await res.json();
205
203
  } else {
206
204
  const styleFile = path.resolve(options.paths.styles, item.style);
207
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- styleFile constructed from config base path and style name
205
+
208
206
  const styleFileData = await fs.promises.readFile(styleFile);
209
207
  styleJSON = JSON.parse(styleFileData);
210
208
  }
211
- } catch (e) {
209
+ } catch (err) {
212
210
  console.log(`Error getting style file "${item.style}"`);
211
+ console.error(err && err.stack ? err.stack : err);
213
212
  return false;
214
213
  }
215
214
 
@@ -224,17 +223,20 @@ async function start(opts) {
224
223
  (styleSourceId, protocol) => {
225
224
  let dataItemId;
226
225
  for (const id of Object.keys(data)) {
227
- // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
228
226
  if (id === styleSourceId) {
229
227
  // Style id was found in data ids, return that id
230
228
  dataItemId = id;
229
+ break;
231
230
  } else {
232
231
  // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
233
- const fileType = Object.keys(data[id])[0];
234
- // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config, fileType is from Object.keys
235
- if (data[id][fileType] === styleSourceId) {
236
- // Style id was found in data filename, return the id that filename belong to
232
+ const sourceData = data[id];
233
+
234
+ if (
235
+ (sourceData.pmtiles && sourceData.pmtiles === styleSourceId) ||
236
+ (sourceData.mbtiles && sourceData.mbtiles === styleSourceId)
237
+ ) {
237
238
  dataItemId = id;
239
+ break;
238
240
  }
239
241
  }
240
242
  }
@@ -289,10 +291,21 @@ async function start(opts) {
289
291
  function dataResolver(styleSourceId) {
290
292
  let resolvedFileType;
291
293
  let resolvedInputFile;
292
- let resolvedSparse = false;
293
294
  let resolvedS3Profile;
294
295
  let resolvedRequestPayer;
295
296
  let resolvedS3Region;
297
+ let resolvedS3UrlFormat;
298
+ let resolvedSparse;
299
+
300
+ // Debug logging to see what we're trying to match
301
+ if (opts.verbose >= 3) {
302
+ console.log(
303
+ `[dataResolver] Looking for styleSourceId: ${styleSourceId}`,
304
+ );
305
+ console.log(
306
+ `[dataResolver] Available data keys: ${Object.keys(data).join(', ')}`,
307
+ );
308
+ }
296
309
 
297
310
  for (const id of Object.keys(data)) {
298
311
  // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
@@ -301,45 +314,64 @@ async function start(opts) {
301
314
  let currentInputFileValue;
302
315
 
303
316
  // Check for recognized file type keys
304
- if (sourceData.hasOwnProperty('pmtiles')) {
317
+ if (Object.hasOwn(sourceData, 'pmtiles')) {
305
318
  currentFileType = 'pmtiles';
306
319
  currentInputFileValue = sourceData.pmtiles;
307
- } else if (sourceData.hasOwnProperty('mbtiles')) {
320
+ } else if (Object.hasOwn(sourceData, 'mbtiles')) {
308
321
  currentFileType = 'mbtiles';
309
322
  currentInputFileValue = sourceData.mbtiles;
310
323
  }
311
324
 
312
325
  if (currentFileType && currentInputFileValue) {
326
+ // Debug logging
327
+ if (opts.verbose >= 3) {
328
+ console.log(
329
+ `[dataResolver] Checking id="${id}", file="${currentInputFileValue}"`,
330
+ );
331
+ }
332
+
313
333
  // Check if this source matches the styleSourceId
314
- if (
315
- styleSourceId === id ||
316
- styleSourceId === currentInputFileValue
317
- ) {
334
+ // Match by ID, by file path, or by base filename
335
+ const matchById = styleSourceId === id;
336
+ const matchByFile = styleSourceId === currentInputFileValue;
337
+ const matchByBasename =
338
+ styleSourceId.includes(currentInputFileValue) ||
339
+ currentInputFileValue.includes(styleSourceId);
340
+
341
+ if (matchById || matchByFile || matchByBasename) {
342
+ if (opts.verbose >= 2) {
343
+ console.log(
344
+ `[dataResolver] Match found for styleSourceId: ${styleSourceId}. (byId=${matchById}, byFile=${matchByFile}, byBasename=${matchByBasename})`,
345
+ );
346
+ }
347
+
318
348
  resolvedFileType = currentFileType;
319
349
  resolvedInputFile = currentInputFileValue;
320
350
 
321
- // Get sparse if present
322
- if (sourceData.hasOwnProperty('sparse')) {
323
- resolvedSparse = !!sourceData.sparse;
324
- } else {
325
- resolvedSparse = false;
326
- }
327
-
328
351
  // Get s3Profile if present
329
- if (sourceData.hasOwnProperty('s3Profile')) {
352
+ if (Object.hasOwn(sourceData, 's3Profile')) {
330
353
  resolvedS3Profile = sourceData.s3Profile;
331
354
  }
332
355
 
356
+ // Get s3UrlFormat if present
357
+ if (Object.hasOwn(sourceData, 's3UrlFormat')) {
358
+ resolvedS3UrlFormat = sourceData.s3UrlFormat;
359
+ }
360
+
333
361
  // Get requestPayer if present
334
- if (sourceData.hasOwnProperty('requestPayer')) {
362
+ if (Object.hasOwn(sourceData, 'requestPayer')) {
335
363
  resolvedRequestPayer = !!sourceData.requestPayer;
336
364
  }
337
365
 
338
366
  // Get s3Region if present
339
- if (sourceData.hasOwnProperty('s3Region')) {
367
+ if (Object.hasOwn(sourceData, 's3Region')) {
340
368
  resolvedS3Region = sourceData.s3Region;
341
369
  }
342
370
 
371
+ // Get sparse: per-source overrides global, default to true
372
+ resolvedSparse =
373
+ sourceData.sparse ?? options.sparse ?? true;
374
+
343
375
  break; // Found our match, exit the outer loop
344
376
  }
345
377
  }
@@ -350,13 +382,23 @@ async function start(opts) {
350
382
  console.warn(
351
383
  `Data source not found for styleSourceId: ${styleSourceId}`,
352
384
  );
385
+ console.warn(
386
+ `Available data sources: ${Object.keys(data)
387
+ .map((id) => {
388
+ // eslint-disable-next-line security/detect-object-injection
389
+ const src = data[id];
390
+ return `${id} -> ${src.pmtiles || src.mbtiles || 'unknown'}`;
391
+ })
392
+ .join(', ')}`,
393
+ );
353
394
  return {
354
395
  inputFile: undefined,
355
396
  fileType: undefined,
356
- sparse: false,
357
397
  s3Profile: undefined,
358
398
  requestPayer: false,
359
399
  s3Region: undefined,
400
+ s3UrlFormat: undefined,
401
+ sparse: true,
360
402
  };
361
403
  }
362
404
 
@@ -384,10 +426,11 @@ async function start(opts) {
384
426
  return {
385
427
  inputFile: resolvedInputFile,
386
428
  fileType: resolvedFileType,
387
- sparse: resolvedSparse,
388
429
  s3Profile: resolvedS3Profile,
389
430
  requestPayer: resolvedRequestPayer,
390
431
  s3Region: resolvedS3Region,
432
+ s3UrlFormat: resolvedS3UrlFormat,
433
+ sparse: resolvedSparse,
391
434
  };
392
435
  },
393
436
  ),
@@ -399,6 +442,8 @@ async function start(opts) {
399
442
  return success;
400
443
  }
401
444
 
445
+ // Collect style loading promises separately
446
+ const stylePromises = [];
402
447
  for (const id of Object.keys(config.styles || {})) {
403
448
  // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of config.styles
404
449
  const item = config.styles[id];
@@ -406,28 +451,39 @@ async function start(opts) {
406
451
  console.log(`Missing "style" property for ${id}`);
407
452
  continue;
408
453
  }
409
- startupPromises.push(addStyle(id, item, true, true));
454
+ stylePromises.push(addStyle(id, item, true, true));
410
455
  }
456
+
457
+ // Wait for styles to finish loading, then load data sources
458
+ // This ensures data sources added by styles are included
459
+ startupPromises.push(
460
+ Promise.all(stylePromises).then(() => {
461
+ const dataLoadPromises = [];
462
+ for (const id of Object.keys(data)) {
463
+ // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
464
+ const item = data[id];
465
+
466
+ if (!item.pmtiles && !item.mbtiles) {
467
+ console.log(
468
+ `Missing "pmtiles" or "mbtiles" property for ${id} data source`,
469
+ );
470
+ continue;
471
+ }
472
+
473
+ dataLoadPromises.push(
474
+ serve_data.add(options, serving.data, item, id, opts),
475
+ );
476
+ }
477
+ return Promise.all(dataLoadPromises);
478
+ }),
479
+ );
480
+
411
481
  startupPromises.push(
412
482
  serve_font(options, serving.fonts, opts).then((sub) => {
413
483
  app.use('/', sub);
414
484
  }),
415
485
  );
416
- for (const id of Object.keys(data)) {
417
- // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
418
- const item = data[id];
419
- // eslint-disable-next-line security/detect-object-injection -- id is from Object.keys of data config
420
- const fileType = Object.keys(data[id])[0];
421
- if (!fileType || !(fileType === 'pmtiles' || fileType === 'mbtiles')) {
422
- console.log(
423
- `Missing "pmtiles" or "mbtiles" property for ${id} data source`,
424
- );
425
- continue;
426
- }
427
- startupPromises.push(serve_data.add(options, serving.data, item, id, opts));
428
- }
429
486
  if (options.serveAllStyles) {
430
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- options.paths.styles is from validated config
431
487
  fs.readdir(options.paths.styles, { withFileTypes: true }, (err, files) => {
432
488
  if (err) {
433
489
  return;
@@ -599,11 +655,10 @@ async function start(opts) {
599
655
  }
600
656
  }
601
657
  try {
602
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- templateFile is from internal templates or config-specified path
603
658
  const content = fs.readFileSync(templateFile, 'utf-8');
604
659
  const compiled = handlebars.compile(content.toString());
605
660
  app.get(urlPath, (req, res, next) => {
606
- if (opts.verbose) {
661
+ if (opts.verbose >= 1) {
607
662
  console.log(`Serving template at path: ${urlPath}`);
608
663
  }
609
664
  let data = {};
@@ -623,7 +678,7 @@ async function start(opts) {
623
678
  if (template === 'wmts') res.set('Content-Type', 'text/xml');
624
679
  return res.status(200).send(compiled(data));
625
680
  } else {
626
- if (opts.verbose) {
681
+ if (opts.verbose >= 1) {
627
682
  console.log(`Forwarding request for: ${urlPath} to next route`);
628
683
  }
629
684
  next('route');
@@ -793,7 +848,7 @@ async function start(opts) {
793
848
  return null;
794
849
  }
795
850
 
796
- if (wmts.hasOwnProperty('serve_rendered') && !wmts.serve_rendered) {
851
+ if (Object.hasOwn(wmts, 'serve_rendered') && !wmts.serve_rendered) {
797
852
  return null;
798
853
  }
799
854
 
package/src/utils.js CHANGED
@@ -40,7 +40,6 @@ export function allowedScales(scale, maxScale = 9) {
40
40
  return 1;
41
41
  }
42
42
 
43
- // eslint-disable-next-line security/detect-non-literal-regexp -- maxScale is a number parameter, not user input
44
43
  const regex = new RegExp(`^[2-${maxScale}]x$`);
45
44
  if (!regex.test(scale)) {
46
45
  return null;
@@ -161,14 +160,13 @@ export function getTileUrls(
161
160
  const hostParts = urlObject.host.split('.');
162
161
  const relativeSubdomainsUsable =
163
162
  hostParts.length > 1 &&
164
- // eslint-disable-next-line security/detect-unsafe-regex -- Simple IPv4 validation, no nested quantifiers
165
- !/^([0-9]{1,3}\.){3}[0-9]{1,3}(\:[0-9]+)?$/.test(urlObject.host);
163
+ !/^([0-9]{1,3}\.){3}[0-9]{1,3}(:[0-9]+)?$/.test(urlObject.host);
166
164
  const newDomains = [];
167
165
  for (const domain of domains) {
168
166
  if (domain.indexOf('*') !== -1) {
169
167
  if (relativeSubdomainsUsable) {
170
168
  const newParts = hostParts.slice(1);
171
- newParts.unshift(domain.replace('*', hostParts[0]));
169
+ newParts.unshift(domain.replace(/\*/g, hostParts[0]));
172
170
  newDomains.push(newParts.join('.'));
173
171
  }
174
172
  } else {
@@ -253,7 +251,7 @@ export function fixTileJSONCenter(tileJSON) {
253
251
  export function readFile(filename) {
254
252
  return new Promise((resolve, reject) => {
255
253
  const sanitizedFilename = path.normalize(filename); // Normalize path, remove ..
256
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- filename is normalized and validated by caller
254
+
257
255
  fs.readFile(String(sanitizedFilename), (err, data) => {
258
256
  if (err) {
259
257
  reject(err);
@@ -276,7 +274,7 @@ export function readFile(filename) {
276
274
  async function getFontPbf(allowedFonts, fontPath, name, range, fallbacks) {
277
275
  // eslint-disable-next-line security/detect-object-injection -- name is validated font name from sanitizedName check
278
276
  if (!allowedFonts || (allowedFonts[name] && fallbacks)) {
279
- const fontMatch = name?.match(/^[\p{L}\p{N} \-\.~!*'()@&=+,#$\[\]]+$/u);
277
+ const fontMatch = name?.match(/^[\p{L}\p{N} \-.~!*'()@&=+,#$[\]]+$/u);
280
278
  const sanitizedName = fontMatch?.[0] || 'invalid';
281
279
  if (!name || typeof name !== 'string' || name.trim() === '' || !fontMatch) {
282
280
  console.error(
@@ -390,16 +388,13 @@ export async function getFontsPbf(
390
388
  export async function listFonts(fontPath) {
391
389
  const existingFonts = {};
392
390
 
393
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- fontPath is from validated config
394
391
  const files = await fsPromises.readdir(fontPath);
395
392
  for (const file of files) {
396
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- file is from readdir of validated fontPath
397
393
  const stats = await fsPromises.stat(path.join(fontPath, file));
398
394
  if (
399
395
  stats.isDirectory() &&
400
396
  (await existsP(path.join(fontPath, file, '0-255.pbf')))
401
397
  ) {
402
- // eslint-disable-next-line security/detect-object-injection -- file is from readdir, used as font name key
403
398
  existingFonts[path.basename(file)] = true;
404
399
  }
405
400
  }
@@ -415,7 +410,7 @@ export async function listFonts(fontPath) {
415
410
  export function isValidHttpUrl(string) {
416
411
  try {
417
412
  return httpTester.test(string);
418
- } catch (e) {
413
+ } catch {
419
414
  return false;
420
415
  }
421
416
  }
@@ -428,7 +423,7 @@ export function isValidHttpUrl(string) {
428
423
  export function isS3Url(string) {
429
424
  try {
430
425
  return s3Tester.test(string) || s3HttpTester.test(string);
431
- } catch (e) {
426
+ } catch {
432
427
  return false;
433
428
  }
434
429
  }
@@ -445,7 +440,7 @@ export function isValidRemoteUrl(string) {
445
440
  s3Tester.test(string) ||
446
441
  s3HttpTester.test(string)
447
442
  );
448
- } catch (e) {
443
+ } catch {
449
444
  return false;
450
445
  }
451
446
  }
@@ -458,7 +453,7 @@ export function isValidRemoteUrl(string) {
458
453
  export function isPMTilesProtocol(string) {
459
454
  try {
460
455
  return pmtilesTester.test(string);
461
- } catch (e) {
456
+ } catch {
462
457
  return false;
463
458
  }
464
459
  }
@@ -471,11 +466,40 @@ export function isPMTilesProtocol(string) {
471
466
  export function isMBTilesProtocol(string) {
472
467
  try {
473
468
  return mbtilesTester.test(string);
474
- } catch (e) {
469
+ } catch {
475
470
  return false;
476
471
  }
477
472
  }
478
473
 
474
+ /**
475
+ * Converts a longitude/latitude point to tile and pixel coordinates at a given zoom level.
476
+ * @param {number} lon - Longitude in degrees.
477
+ * @param {number} lat - Latitude in degrees.
478
+ * @param {number} zoom - Zoom level.
479
+ * @param {number} tileSize - Size of the tile in pixels (e.g., 256 or 512).
480
+ * @returns {{tileX: number, tileY: number, pixelX: number, pixelY: number}} - Tile and pixel coordinates.
481
+ */
482
+ export function lonLatToTilePixel(lon, lat, zoom, tileSize) {
483
+ let siny = Math.sin((lat * Math.PI) / 180);
484
+ // Truncating to 0.9999 effectively limits latitude to 89.189. This is
485
+ // about a third of a tile past the edge of the world tile.
486
+ siny = Math.min(Math.max(siny, -0.9999), 0.9999);
487
+
488
+ const xWorld = tileSize * (0.5 + lon / 360);
489
+ const yWorld =
490
+ tileSize * (0.5 - Math.log((1 + siny) / (1 - siny)) / (4 * Math.PI));
491
+
492
+ const scale = 1 << zoom;
493
+
494
+ const tileX = Math.floor((xWorld * scale) / tileSize);
495
+ const tileY = Math.floor((yWorld * scale) / tileSize);
496
+
497
+ const pixelX = Math.floor(xWorld * scale) - tileX * tileSize;
498
+ const pixelY = Math.floor(yWorld * scale) - tileY * tileSize;
499
+
500
+ return { tileX, tileY, pixelX, pixelY };
501
+ }
502
+
479
503
  /**
480
504
  * Fetches tile data from either PMTiles or MBTiles source.
481
505
  * @param {object} source - The source object, which may contain a mbtiles object, or pmtiles object.
@@ -487,15 +511,18 @@ export function isMBTilesProtocol(string) {
487
511
  */
488
512
  export async function fetchTileData(source, sourceType, z, x, y) {
489
513
  if (sourceType === 'pmtiles') {
490
- return await new Promise(async (resolve) => {
514
+ try {
491
515
  const tileinfo = await getPMtilesTile(source, z, x, y);
492
- if (!tileinfo?.data) return resolve(null);
493
- resolve({ data: tileinfo.data, headers: tileinfo.header });
494
- });
516
+ if (!tileinfo?.data) return null;
517
+ return { data: tileinfo.data, headers: tileinfo.header };
518
+ } catch (error) {
519
+ console.error('Error fetching PMTiles tile:', error);
520
+ return null;
521
+ }
495
522
  } else if (sourceType === 'mbtiles') {
496
- return await new Promise((resolve) => {
523
+ return new Promise((resolve) => {
497
524
  source.getTile(z, x, y, (err, tileData, tileHeader) => {
498
- if (err) {
525
+ if (err || tileData == null) {
499
526
  return resolve(null);
500
527
  }
501
528
  resolve({ data: tileData, headers: tileHeader });