• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

consbio / mbgl-renderer / 5180348996

05 Jun 2023 06:00PM UTC coverage: 67.416% (-3.6%) from 71.046%
5180348996

push

github

web-flow
ENH: Log errors to logger in addition to raising exceptions (#108)

105 of 150 branches covered (70.0%)

Branch coverage included in aggregate %.

56 of 56 new or added lines in 1 file covered. (100.0%)

195 of 295 relevant lines covered (66.1%)

180050.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

66.43
/src/render.js
1
/* eslint-disable no-new */
2
import fs from 'fs'
3
import path from 'path'
4
// sharp must be before zlib and other imports or sharp gets wrong version of zlib and breaks on some servers
5
import sharp from 'sharp'
6
import zlib from 'zlib'
7
import geoViewport from '@mapbox/geo-viewport'
8
import maplibre from '@maplibre/maplibre-gl-native'
9
import MBTiles from '@mapbox/mbtiles'
10
import pino from 'pino'
11
import webRequest from 'request'
12
import urlLib from 'url'
13

14
const TILE_REGEXP = RegExp('mbtiles://([^/]+)/(\\d+)/(\\d+)/(\\d+)')
4✔
15
const MBTILES_REGEXP = /mbtiles:\/\/(\S+?)(?=[/"]+)/gi
4✔
16

17
const logger = pino({
4✔
18
    formatters: {
19
        level: (label) => ({ level: label }),
24✔
20
    },
21
    redact: {
22
        paths: ['pid', 'hostname'],
23
        remove: true,
24
    },
25
})
26

27
maplibre.on('message', (msg) => {
4✔
28
    switch (msg.severity) {
12!
29
        case 'ERROR': {
30
            logger.error(msg.text)
8✔
31
            break
8✔
32
        }
33
        case 'WARNING': {
34
            if (msg.class === 'ParseStyle') {
×
35
                // can't throw an exception here or it crashes NodeJS process
36
                logger.error(`Error parsing style: ${msg.text}`)
×
37
            } else {
38
                logger.warn(msg.text)
×
39
            }
40
            break
×
41
        }
42

43
        default: {
44
            // NOTE: includes INFO
45
            logger.debug(msg.text)
4✔
46
            break
4✔
47
        }
48
    }
49
})
50

51
export const isMapboxURL = (url) => url.startsWith('mapbox://')
280✔
52
export const isMapboxStyleURL = (url) => url.startsWith('mapbox://styles/')
4✔
53
const isMBTilesURL = (url) => url.startsWith('mbtiles://')
272✔
54

55
// normalize functions derived from: https://github.com/mapbox/mapbox-gl-js/blob/master/src/util/mapbox.js
56

57
/**
58
 * Normalize a Mapbox source URL to a full URL
59
 * @param {string} url - url to mapbox source in style json, e.g. "url": "mapbox://mapbox.mapbox-streets-v7"
60
 * @param {string} token - mapbox public token
61
 */
62
const normalizeMapboxSourceURL = (url, token) => {
4✔
63
    try {
4✔
64
        const urlObject = urlLib.parse(url)
4✔
65
        urlObject.query = urlObject.query || {}
4✔
66
        urlObject.pathname = `/v4/${url.split('mapbox://')[1]}.json`
4✔
67
        urlObject.protocol = 'https'
4✔
68
        urlObject.host = 'api.mapbox.com'
4✔
69
        urlObject.query.secure = true
4✔
70
        urlObject.query.access_token = token
4✔
71
        return urlLib.format(urlObject)
4✔
72
    } catch (e) {
73
        const msg = `Could not normalize Mapbox source URL: ${url}\n${e}`
×
74
        logger.error(msg)
×
75
        throw new Error(msg)
×
76
    }
77
}
78

79
/**
80
 * Normalize a Mapbox tile URL to a full URL
81
 * @param {string} url - url to mapbox tile in style json or resolved from source
82
 * e.g. mapbox://tiles/mapbox.mapbox-streets-v7/1/0/1.vector.pbf
83
 * @param {string} token - mapbox public token
84
 */
85
const normalizeMapboxTileURL = (url, token) => {
4✔
86
    try {
×
87
        const urlObject = urlLib.parse(url)
×
88
        urlObject.query = urlObject.query || {}
×
89
        urlObject.pathname = `/v4${urlObject.path}`
×
90
        urlObject.protocol = 'https'
×
91
        urlObject.host = 'a.tiles.mapbox.com'
×
92
        urlObject.query.access_token = token
×
93
        return urlLib.format(urlObject)
×
94
    } catch (e) {
95
        const msg = `Could not normalize Mapbox tile URL: ${url}\n${e}`
×
96
        logger.error(msg)
×
97
        throw new Error(msg)
×
98
    }
99
}
100

101
/**
102
 * Normalize a Mapbox style URL to a full URL
103
 * @param {string} url - url to mapbox source in style json, e.g. "url": "mapbox://styles/mapbox/streets-v9"
104
 * @param {string} token - mapbox public token
105
 */
106
export const normalizeMapboxStyleURL = (url, token) => {
4✔
107
    try {
×
108
        const urlObject = urlLib.parse(url)
×
109
        urlObject.query = {
×
110
            access_token: token,
111
            secure: true,
112
        }
113
        urlObject.pathname = `styles/v1${urlObject.path}`
×
114
        urlObject.protocol = 'https'
×
115
        urlObject.host = 'api.mapbox.com'
×
116
        return urlLib.format(urlObject)
×
117
    } catch (e) {
118
        const msg = `Could not normalize Mapbox style URL: ${url}\n${e}`
×
119
        logger.error(msg)
×
120
        throw new Error(msg)
×
121
    }
122
}
123

124
/**
125
 * Normalize a Mapbox sprite URL to a full URL
126
 * @param {string} url - url to mapbox sprite, e.g. "url": "mapbox://sprites/mapbox/streets-v9.png"
127
 * @param {string} token - mapbox public token
128
 *
129
 * Returns {string} - url, e.g., "https://api.mapbox.com/styles/v1/mapbox/streets-v9/sprite.png?access_token=<token>"
130
 */
131
export const normalizeMapboxSpriteURL = (url, token) => {
4✔
132
    try {
×
133
        const extMatch = /(\.png|\.json)$/g.exec(url)
×
134
        const ratioMatch = /(@\d+x)\./g.exec(url)
×
135
        const trimIndex = Math.min(
×
136
            ratioMatch != null ? ratioMatch.index : Infinity,
×
137
            extMatch.index
138
        )
139
        const urlObject = urlLib.parse(url.substring(0, trimIndex))
×
140

141
        const extPart = extMatch[1]
×
142
        const ratioPart = ratioMatch != null ? ratioMatch[1] : ''
×
143
        urlObject.query = urlObject.query || {}
×
144
        urlObject.query.access_token = token
×
145
        urlObject.pathname = `/styles/v1${urlObject.path}/sprite${ratioPart}${extPart}`
×
146
        urlObject.protocol = 'https'
×
147
        urlObject.host = 'api.mapbox.com'
×
148
        return urlLib.format(urlObject)
×
149
    } catch (e) {
150
        const msg = `Could not normalize Mapbox sprite URL: ${url}\n${e}`
×
151
        logger.error(msg)
×
152
        throw new Error(msg)
×
153
    }
154
}
155

156
/**
157
 * Normalize a Mapbox glyph URL to a full URL
158
 * @param {string} url - url to mapbox sprite, e.g. "url": "mapbox://sprites/mapbox/streets-v9.png"
159
 * @param {string} token - mapbox public token
160
 *
161
 * Returns {string} - url, e.g., "https://api.mapbox.com/styles/v1/mapbox/streets-v9/sprite.png?access_token=<token>"
162
 */
163
export const normalizeMapboxGlyphURL = (url, token) => {
4✔
164
    try {
×
165
        const urlObject = urlLib.parse(url)
×
166
        urlObject.query = urlObject.query || {}
×
167
        urlObject.query.access_token = token
×
168
        urlObject.pathname = `/fonts/v1${urlObject.path}`
×
169
        urlObject.protocol = 'https'
×
170
        urlObject.host = 'api.mapbox.com'
×
171
        return urlLib.format(urlObject)
×
172
    } catch (e) {
173
        const msg = `Could not normalize Mapbox glyph URL: ${url}\n${e}`
×
174
        logger.error(msg)
×
175
        throw new Error(msg)
×
176
    }
177
}
178

179
/**
180
 * Very simplistic function that splits out mbtiles service name from the URL
181
 *
182
 * @param {String} url - URL to resolve
183
 */
184
const resolveNamefromURL = (url) => url.split('://')[1].split('/')[0]
232✔
185

186
/**
187
 * Resolve a URL of a local mbtiles file to a file path
188
 * Expected to follow this format "mbtiles://<service_name>/*"
189
 *
190
 * @param {String} tilePath - path containing mbtiles files
191
 * @param {String} url - url of a data source in style.json file.
192
 */
193
const resolveMBTilesURL = (tilePath, url) =>
4✔
194
    path.format({
220✔
195
        dir: tilePath,
196
        name: resolveNamefromURL(url),
197
        ext: '.mbtiles',
198
    })
199

200
/**
201
 * Given a URL to a local mbtiles file, get the TileJSON for that to load correct tiles.
202
 *
203
 * @param {String} tilePath - path containing mbtiles files.
204
 * @param {String} url - url of a data source in style.json file.
205
 * @param {function} callback - function to call with (err, {data}).
206
 */
207
const getLocalTileJSON = (tilePath, url, callback) => {
4✔
208
    const mbtilesFilename = resolveMBTilesURL(tilePath, url)
12✔
209
    const service = resolveNamefromURL(url)
12✔
210

211
    new MBTiles(mbtilesFilename, (err, mbtiles) => {
12✔
212
        if (err) {
12!
213
            callback(err)
×
214
            return null
×
215
        }
216

217
        mbtiles.getInfo((infoErr, info) => {
12✔
218
            if (infoErr) {
12!
219
                callback(infoErr)
×
220
                return null
×
221
            }
222

223
            const { minzoom, maxzoom, center, bounds, format } = info
12✔
224

225
            const ext = format === 'pbf' ? '.pbf' : ''
12✔
226

227
            const tileJSON = {
12✔
228
                tilejson: '1.0.0',
229
                tiles: [`mbtiles://${service}/{z}/{x}/{y}${ext}`],
230
                minzoom,
231
                maxzoom,
232
                center,
233
                bounds,
234
            }
235

236
            callback(null, { data: Buffer.from(JSON.stringify(tileJSON)) })
12✔
237
            return null
12✔
238
        })
239

240
        return null
12✔
241
    })
242
}
243

244
/**
245
 * Fetch a tile from a local mbtiles file.
246
 *
247
 * @param {String} tilePath - path containing mbtiles files.
248
 * @param {String} url - url of a data source in style.json file.
249
 * @param {function} callback - function to call with (err, {data}).
250
 */
251
const getLocalTile = (tilePath, url, callback) => {
4✔
252
    const matches = url.match(TILE_REGEXP)
208✔
253
    const [z, x, y] = matches.slice(matches.length - 3, matches.length)
208✔
254
    const isVector = path.extname(url) === '.pbf'
208✔
255
    const mbtilesFile = resolveMBTilesURL(tilePath, url)
208✔
256

257
    new MBTiles(mbtilesFile, (err, mbtiles) => {
208✔
258
        if (err) {
208!
259
            callback(err)
×
260
            return null
×
261
        }
262

263
        mbtiles.getTile(z, x, y, (tileErr, data) => {
208✔
264
            if (tileErr) {
208✔
265
                callback(null, {})
96✔
266
                return null
96✔
267
            }
268

269
            if (isVector) {
112✔
270
                // if the tile is compressed, unzip it (for vector tiles only!)
271
                zlib.unzip(data, (unzipErr, unzippedData) => {
16✔
272
                    callback(unzipErr, { data: unzippedData })
16✔
273
                })
274
            } else {
275
                callback(null, { data })
96✔
276
            }
277

278
            return null
112✔
279
        })
280

281
        return null
208✔
282
    })
283
}
284

285
/**
286
 * Fetch a remotely hosted tile.
287
 * Empty or missing tiles return null data to the callback function, which
288
 * result in those tiles not rendering but no errors being raised.
289
 *
290
 * @param {String} url - URL of the tile
291
 * @param {function} callback - callback to call with (err, {data})
292
 */
293
const getRemoteTile = (url, callback) => {
4✔
294
    webRequest(
48✔
295
        {
296
            url,
297
            encoding: null,
298
            gzip: true,
299
        },
300
        (err, res, data) => {
301
            if (err) {
48!
302
                return callback(err)
×
303
            }
304

305
            switch (res.statusCode) {
48!
306
                case 200: {
307
                    return callback(null, { data })
4✔
308
                }
309
                case 204: {
310
                    // No data for this url
311
                    return callback(null, {})
×
312
                }
313
                case 404: {
314
                    // Tile not found
315
                    // this may be valid for some tilesets that have partial coverage
316
                    // on servers that do not return blank tiles in these areas.
317
                    logger.warn(`Missing tile at: ${url}`)
44✔
318
                    return callback(null, {})
44✔
319
                }
320
                default: {
321
                    // assume error
322
                    const msg = `request for remote tile failed: ${url} (status: ${res.statusCode})`
×
323
                    logger.error(msg)
×
324
                    return callback(new Error(msg))
×
325
                }
326
            }
327
        }
328
    )
329
}
330

331
/**
332
 * Fetch a remotely hosted asset: glyph, sprite, etc
333
 * Anything other than a HTTP 200 response results in an exception.
334
 *
335
 *
336
 * @param {String} url - URL of the asset
337
 * @param {function} callback - callback to call with (err, {data})
338
 */
339
const getRemoteAsset = (url, callback) => {
4✔
340
    webRequest(
24✔
341
        {
342
            url,
343
            encoding: null,
344
            gzip: true,
345
        },
346
        (err, res, data) => {
347
            if (err) {
24✔
348
                return callback(err)
4✔
349
            }
350

351
            switch (res.statusCode) {
20✔
352
                case 200: {
353
                    return callback(null, { data })
12✔
354
                }
355
                default: {
356
                    const msg = `request for remote asset failed: ${res.request.uri.href} (status: ${res.statusCode})`
8✔
357
                    logger.error(msg)
8✔
358
                    return callback(new Error(msg))
8✔
359
                }
360
            }
361
        }
362
    )
363
}
364

365
/**
366
 * Fetch a remotely hosted asset: glyph, sprite, etc
367
 * Anything other than a HTTP 200 response results in an exception.
368
 *
369
 * @param {String} url - URL of the asset
370
 * returns a Promise
371
 */
372
const getRemoteAssetPromise = (url) => {
4✔
373
    return new Promise((resolve, reject) => {
12✔
374
        getRemoteAsset(url, (err, data) => {
12✔
375
            if (err) {
12✔
376
                return reject(err)
4✔
377
            }
378
            return resolve(data)
8✔
379
        })
380
    })
381
}
382

383
/**
384
 * requestHandler constructs a request handler for the map to load resources.
385
 *
386
 * @param {String} - path to tilesets (optional)
387
 * @param {String} - Mapbox GL token (optional; required for any Mapbox hosted resources)
388
 */
389
const requestHandler =
390
    (tilePath, token) =>
4✔
391
    ({ url, kind }, callback) => {
84✔
392
        const isMapbox = isMapboxURL(url)
280✔
393
        if (isMapbox && !token) {
280!
394
            const msg = 'mapbox access token is required'
×
395
            logger.error(msg)
×
396
            return callback(new Error(msg))
×
397
        }
398

399
        try {
280✔
400
            switch (kind) {
280!
401
                case 2: {
402
                    // source
403
                    if (isMBTilesURL(url)) {
16✔
404
                        getLocalTileJSON(tilePath, url, callback)
12✔
405
                    } else if (isMapbox) {
4!
406
                        getRemoteAsset(
4✔
407
                            normalizeMapboxSourceURL(url, token),
408
                            callback
409
                        )
410
                    } else {
411
                        getRemoteAsset(url, callback)
×
412
                    }
413
                    break
16✔
414
                }
415
                case 3: {
416
                    // tile
417
                    if (isMBTilesURL(url)) {
256✔
418
                        getLocalTile(tilePath, url, callback)
208✔
419
                    } else if (isMapbox) {
48!
420
                        // This seems to be due to a bug in how the mapbox tile
421
                        // JSON is handled within mapbox-gl-native
422
                        // since it returns fully resolved tiles!
423
                        getRemoteTile(
×
424
                            normalizeMapboxTileURL(url, token),
425
                            callback
426
                        )
427
                    } else {
428
                        getRemoteTile(url, callback)
48✔
429
                    }
430
                    break
256✔
431
                }
432
                case 4: {
433
                    // glyph
434
                    getRemoteAsset(
4✔
435
                        isMapbox
4!
436
                            ? normalizeMapboxGlyphURL(url, token)
437
                            : urlLib.parse(url),
438
                        callback
439
                    )
440
                    break
4✔
441
                }
442
                case 5: {
443
                    // sprite image
444
                    getRemoteAsset(
×
445
                        isMapbox
×
446
                            ? normalizeMapboxSpriteURL(url, token)
447
                            : urlLib.parse(url),
448
                        callback
449
                    )
450
                    break
×
451
                }
452
                case 6: {
453
                    // sprite json
454
                    getRemoteAsset(
×
455
                        isMapbox
×
456
                            ? normalizeMapboxSpriteURL(url, token)
457
                            : urlLib.parse(url),
458
                        callback
459
                    )
460
                    break
×
461
                }
462
                case 7: {
463
                    // image source
464
                    getRemoteAsset(urlLib.parse(url), callback)
4✔
465
                    break
4✔
466
                }
467
                default: {
468
                    // NOT HANDLED!
469
                    const msg = `error Request kind not handled: ${kind}`
×
470
                    logger.error(msg)
×
471
                    throw new Error(msg)
×
472
                }
473
            }
474
        } catch (err) {
475
            const msg = `Error while making resource request to: ${url}\n${err}`
×
476
            logger.error(msg)
×
477
            return callback(msg)
×
478
        }
479
    }
480

481
/**
482
 * Load an icon image from base64 data or a URL and add it to the map.
483
 *
484
 * @param {Object} map - Mapbox GL map object
485
 * @param {String} id - id of image to add
486
 * @param {Object} options - options object with {url, pixelRatio, sdf}.  url is required
487
 */
488
const loadImage = async (map, id, { url, pixelRatio = 1, sdf = false }) => {
4✔
489
    if (!url) {
28✔
490
        const msg = `Invalid url for image: ${id}`
4✔
491
        logger.error(msg)
4✔
492
        throw new Error(msg)
4✔
493
    }
494

495
    try {
24✔
496
        let imgBuffer = null
24✔
497
        if (url.startsWith('data:')) {
24✔
498
            imgBuffer = Buffer.from(url.split('base64,')[1], 'base64')
12✔
499
        } else {
500
            const img = await getRemoteAssetPromise(url)
12✔
501
            imgBuffer = img.data
8✔
502
        }
503
        const img = sharp(imgBuffer)
20✔
504
        const metadata = await img.metadata()
20✔
505
        const data = await img.raw().toBuffer()
16✔
506
        await map.addImage(id, data, {
16✔
507
            width: metadata.width,
508
            height: metadata.height,
509
            pixelRatio,
510
            sdf,
511
        })
512
    } catch (e) {
513
        const msg = `Error loading icon image: ${id}\n${e}`
8✔
514
        logger.error(msg)
8✔
515
        throw new Error(msg)
8✔
516
    }
517
}
518

519
/**
520
 * Load all icon images to the map.
521
 * @param {Object} map - Mapbox GL map object
522
 * @param {Object} images - object with {id: {url, ...other image properties}}
523
 */
524
const loadImages = async (map, images) => {
4✔
525
    if (images !== null) {
84✔
526
        const imageRequests = Object.entries(images).map(async (image) => {
20✔
527
            await loadImage(map, ...image)
28✔
528
        })
529

530
        // await for all requests to complete
531
        await Promise.all(imageRequests)
20✔
532
    }
533
}
534

535
/**
536
 * Render the map, returning a Promise.
537
 *
538
 * @param {Object} map - Mapbox GL map object
539
 * @param {Object} options - Mapbox GL map options
540
 * @returns
541
 */
542
const renderMap = (map, options) => {
4✔
543
    return new Promise((resolve, reject) => {
72✔
544
        map.render(options, (err, buffer) => {
72✔
545
            if (err) {
72✔
546
                return reject(err)
8✔
547
            }
548
            return resolve(buffer)
64✔
549
        })
550
    })
551
}
552

553
/**
554
 * Convert premultiplied image buffer from Mapbox GL to RGBA PNG format.
555
 * @param {Uint8Array} buffer - image data buffer
556
 * @param {Number} width - image width
557
 * @param {Number} height - image height
558
 * @param {Number} ratio - image pixel ratio
559
 * @returns
560
 */
561
const toPNG = async (buffer, width, height, ratio) => {
4✔
562
    // Un-premultiply pixel values
563
    // Mapbox GL buffer contains premultiplied values, which are not handled correctly by sharp
564
    // https://github.com/mapbox/mapbox-gl-native/issues/9124
565
    // since we are dealing with 8-bit RGBA values, normalize alpha onto 0-255 scale and divide
566
    // it out of RGB values
567

568
    for (let i = 0; i < buffer.length; i += 4) {
64✔
569
        const alpha = buffer[i + 3]
8,850,752✔
570
        const norm = alpha / 255
8,850,752✔
571
        if (alpha === 0) {
8,850,752✔
572
            buffer[i] = 0
1,593,308✔
573
            buffer[i + 1] = 0
1,593,308✔
574
            buffer[i + 2] = 0
1,593,308✔
575
        } else {
576
            buffer[i] /= norm
7,257,444✔
577
            buffer[i + 1] = buffer[i + 1] / norm
7,257,444✔
578
            buffer[i + 2] = buffer[i + 2] / norm
7,257,444✔
579
        }
580
    }
581

582
    return sharp(buffer, {
64✔
583
        raw: {
584
            width: width * ratio,
585
            height: height * ratio,
586
            channels: 4,
587
        },
588
    })
589
        .png()
590
        .toBuffer()
591
}
592

593
/**
594
 * Asynchronously render a map using Mapbox GL, based on layers specified in style.
595
 * Returns PNG image data (via async / Promise).
596
 *
597
 * If zoom and center are not provided, bounds must be provided
598
 * and will be used to calculate center and zoom based on image dimensions.
599
 *
600
 * @param {Object} style - Mapbox GL style object
601
 * @param {number} width - width of output map (default: 1024)
602
 * @param {number} height - height of output map (default: 1024)
603
 * @param {Object} - configuration object containing style, zoom, center: [lng, lat],
604
 * width, height, bounds: [west, south, east, north], ratio, padding
605
 * @param {String} tilePath - path to directory containing local mbtiles files that are
606
 * referenced from the style.json as "mbtiles://<tileset>"
607
 */
608
export const render = async (style, width = 1024, height = 1024, options) => {
4!
609
    const {
610
        bounds = null,
68✔
611
        bearing = 0,
104✔
612
        pitch = 0,
104✔
613
        token = null,
104✔
614
        ratio = 1,
104✔
615
        padding = 0,
80✔
616
        images = null,
88✔
617
    } = options
108✔
618
    let { center = null, zoom = null, tilePath = null } = options
108!
619

620
    if (!style) {
108!
621
        const msg = 'style is a required parameter'
×
622
        throw new Error(msg)
×
623
    }
624
    if (!(width && height)) {
108!
625
        const msg =
626
            'width and height are required parameters and must be non-zero'
×
627
        throw new Error(msg)
×
628
    }
629

630
    if (center !== null) {
108✔
631
        if (center.length !== 2) {
68!
632
            const msg = `Center must be longitude,latitude.  Invalid value found: ${[
×
633
                ...center,
634
            ]}`
635
            throw new Error(msg)
×
636
        }
637

638
        if (Math.abs(center[0]) > 180) {
68!
639
            const msg = `Center longitude is outside world bounds (-180 to 180 deg): ${center[0]}`
×
640
            throw new Error(msg)
×
641
        }
642

643
        if (Math.abs(center[1]) > 90) {
68!
644
            const msg = `Center latitude is outside world bounds (-90 to 90 deg): ${center[1]}`
×
645
            throw new Error(msg)
×
646
        }
647
    }
648

649
    if (zoom !== null && (zoom < 0 || zoom > 22)) {
108!
650
        const msg = `Zoom level is outside supported range (0-22): ${zoom}`
×
651
        throw new Error(msg)
×
652
    }
653

654
    if (bearing !== null && (bearing < 0 || bearing > 360)) {
108!
655
        const msg = `bearing is outside supported range (0-360): ${bearing}`
×
656
        throw new Error(msg)
×
657
    }
658

659
    if (pitch !== null && (pitch < 0 || pitch > 60)) {
108!
660
        const msg = `pitch is outside supported range (0-60): ${pitch}`
×
661
        throw new Error(msg)
×
662
    }
663

664
    if (bounds !== null) {
108✔
665
        if (bounds.length !== 4) {
40!
666
            const msg = `Bounds must be west,south,east,north.  Invalid value found: ${[
×
667
                ...bounds,
668
            ]}`
669
            throw new Error(msg)
×
670
        }
671

672
        if (padding) {
40✔
673
            // padding must not be greater than width / 2 and height / 2
674
            if (Math.abs(padding) >= width / 2) {
24✔
675
                throw new Error('Padding must be less than width / 2')
8✔
676
            }
677
            if (Math.abs(padding) >= height / 2) {
16✔
678
                throw new Error('Padding must be less than height / 2')
8✔
679
            }
680
        }
681
    }
682

683
    // calculate zoom and center from bounds and image dimensions
684
    if (bounds !== null && (zoom === null || center === null)) {
92!
685
        const viewport = geoViewport.viewport(
24✔
686
            bounds,
687
            // add padding to width and height to effectively
688
            // zoom out the target zoom level.
689
            [width - 2 * padding, height - 2 * padding],
690
            undefined,
691
            undefined,
692
            undefined,
693
            true
694
        )
695
        zoom = Math.max(viewport.zoom - 1, 0)
24✔
696
        /* eslint-disable prefer-destructuring */
697
        center = viewport.center
24✔
698
    }
699

700
    // validate that all local mbtiles referenced in style are
701
    // present in tilePath and that tilePath is not null
702
    if (tilePath) {
92✔
703
        tilePath = path.normalize(tilePath)
52✔
704
    }
705

706
    const localMbtilesMatches = JSON.stringify(style).match(MBTILES_REGEXP)
92✔
707
    if (localMbtilesMatches && !tilePath) {
92✔
708
        const msg =
709
            'Style has local mbtiles file sources, but no tilePath is set'
4✔
710
        throw new Error(msg)
4✔
711
    }
712

713
    if (localMbtilesMatches) {
88✔
714
        localMbtilesMatches.forEach((name) => {
36✔
715
            const mbtileFilename = path.normalize(
40✔
716
                path.format({
717
                    dir: tilePath,
718
                    name: name.split('://')[1],
719
                    ext: '.mbtiles',
720
                })
721
            )
722
            if (!fs.existsSync(mbtileFilename)) {
40✔
723
                const msg = `Mbtiles file ${path.format({
4✔
724
                    name,
725
                    ext: '.mbtiles',
726
                })} in style file is not found in: ${path.resolve(tilePath)}`
727
                throw new Error(msg)
4✔
728
            }
729
        })
730
    }
731

732
    const map = new maplibre.Map({
84✔
733
        request: requestHandler(tilePath, token),
734
        ratio,
735
    })
736

737
    map.load(style)
84✔
738

739
    await loadImages(map, images)
84✔
740

741
    const buffer = await renderMap(map, {
72✔
742
        zoom,
743
        center,
744
        height,
745
        width,
746
        bearing,
747
        pitch,
748
    })
749

750
    return toPNG(buffer, width, height, ratio)
64✔
751
}
752

753
export default render
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc