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

consbio / mbgl-renderer / 4485162904

22 Mar 2023 12:38AM UTC coverage: 71.046% (-0.4%) from 71.465%
4485162904

push

github

GitHub
ENH: Use pino for structured logging (#106)

105 of 150 branches covered (70.0%)

Branch coverage included in aggregate %.

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

187 of 261 relevant lines covered (71.65%)

101752.73 hits per line

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

70.15
/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

13
import urlLib from 'url'
14

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

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

28
maplibre.on('message', (msg) => {
1✔
29
    // console.log(msg.severity, msg.class, msg.text)
30
    switch (msg.severity) {
3!
31
        case 'ERROR': {
32
            logger.error(msg.text)
2✔
33
            break
2✔
34
        }
35
        case 'WARNING': {
36
            if (msg.class === 'ParseStyle') {
×
37
                logger.error(`Error parsing style: ${msg.text}`)
×
38
            } else {
39
                logger.warn(msg.text)
×
40
            }
41
            break
×
42
        }
43

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

52
export const isMapboxURL = (url) => url.startsWith('mapbox://')
70✔
53
export const isMapboxStyleURL = (url) => url.startsWith('mapbox://styles/')
1✔
54
const isMBTilesURL = (url) => url.startsWith('mbtiles://')
68✔
55

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

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

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

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

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

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

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

170
/**
171
 * Very simplistic function that splits out mbtiles service name from the URL
172
 *
173
 * @param {String} url - URL to resolve
174
 */
175
const resolveNamefromURL = (url) => url.split('://')[1].split('/')[0]
58✔
176

177
/**
178
 * Resolve a URL of a local mbtiles file to a file path
179
 * Expected to follow this format "mbtiles://<service_name>/*"
180
 *
181
 * @param {String} tilePath - path containing mbtiles files
182
 * @param {String} url - url of a data source in style.json file.
183
 */
184
const resolveMBTilesURL = (tilePath, url) =>
1✔
185
    path.format({
55✔
186
        dir: tilePath,
187
        name: resolveNamefromURL(url),
188
        ext: '.mbtiles',
189
    })
190

191
/**
192
 * Given a URL to a local mbtiles file, get the TileJSON for that to load correct tiles.
193
 *
194
 * @param {String} tilePath - path containing mbtiles files.
195
 * @param {String} url - url of a data source in style.json file.
196
 * @param {function} callback - function to call with (err, {data}).
197
 */
198
const getLocalTileJSON = (tilePath, url, callback) => {
1✔
199
    const mbtilesFilename = resolveMBTilesURL(tilePath, url)
3✔
200
    const service = resolveNamefromURL(url)
3✔
201

202
    new MBTiles(mbtilesFilename, (err, mbtiles) => {
3✔
203
        if (err) {
3!
204
            callback(err)
×
205
            return null
×
206
        }
207

208
        mbtiles.getInfo((infoErr, info) => {
3✔
209
            if (infoErr) {
3!
210
                callback(infoErr)
×
211
                return null
×
212
            }
213

214
            const { minzoom, maxzoom, center, bounds, format } = info
3✔
215

216
            const ext = format === 'pbf' ? '.pbf' : ''
3✔
217

218
            const tileJSON = {
3✔
219
                tilejson: '1.0.0',
220
                tiles: [`mbtiles://${service}/{z}/{x}/{y}${ext}`],
221
                minzoom,
222
                maxzoom,
223
                center,
224
                bounds,
225
            }
226

227
            callback(null, { data: Buffer.from(JSON.stringify(tileJSON)) })
3✔
228
            return null
3✔
229
        })
230

231
        return null
3✔
232
    })
233
}
234

235
/**
236
 * Fetch a tile from a local mbtiles file.
237
 *
238
 * @param {String} tilePath - path containing mbtiles files.
239
 * @param {String} url - url of a data source in style.json file.
240
 * @param {function} callback - function to call with (err, {data}).
241
 */
242
const getLocalTile = (tilePath, url, callback) => {
1✔
243
    const matches = url.match(TILE_REGEXP)
52✔
244
    const [z, x, y] = matches.slice(matches.length - 3, matches.length)
52✔
245
    const isVector = path.extname(url) === '.pbf'
52✔
246
    const mbtilesFile = resolveMBTilesURL(tilePath, url)
52✔
247

248
    new MBTiles(mbtilesFile, (err, mbtiles) => {
52✔
249
        if (err) {
52!
250
            callback(err)
×
251
            return null
×
252
        }
253

254
        mbtiles.getTile(z, x, y, (tileErr, data) => {
52✔
255
            if (tileErr) {
52✔
256
                callback(null, {})
24✔
257
                return null
24✔
258
            }
259

260
            if (isVector) {
28✔
261
                // if the tile is compressed, unzip it (for vector tiles only!)
262
                zlib.unzip(data, (unzipErr, unzippedData) => {
4✔
263
                    callback(unzipErr, { data: unzippedData })
4✔
264
                })
265
            } else {
266
                callback(null, { data })
24✔
267
            }
268

269
            return null
28✔
270
        })
271

272
        return null
52✔
273
    })
274
}
275

276
/**
277
 * Fetch a remotely hosted tile.
278
 * Empty or missing tiles return null data to the callback function, which
279
 * result in those tiles not rendering but no errors being raised.
280
 *
281
 * @param {String} url - URL of the tile
282
 * @param {function} callback - callback to call with (err, {data})
283
 */
284
const getRemoteTile = (url, callback) => {
1✔
285
    webRequest(
12✔
286
        {
287
            url,
288
            encoding: null,
289
            gzip: true,
290
        },
291
        (err, res, data) => {
292
            if (err) {
12!
293
                return callback(err)
×
294
            }
295

296
            switch (res.statusCode) {
12!
297
                case 200: {
298
                    return callback(null, { data })
1✔
299
                }
300
                case 204: {
301
                    // No data for this url
302
                    return callback(null, {})
×
303
                }
304
                case 404: {
305
                    // Tile not found
306
                    // this may be valid for some tilesets that have partial coverage
307
                    // on servers that do not return blank tiles in these areas.
308
                    logger.warn(`Missing tile at: ${url}`)
11✔
309
                    return callback(null, {})
11✔
310
                }
311
                default: {
312
                    // assume error
313
                    return callback(
×
314
                        new Error(
315
                            `request for remote tile failed: ${url} (status: ${res.statusCode})`
316
                        )
317
                    )
318
                }
319
            }
320
        }
321
    )
322
}
323

324
/**
325
 * Fetch a remotely hosted asset: glyph, sprite, etc
326
 * Anything other than a HTTP 200 response results in an exception.
327
 *
328
 *
329
 * @param {String} url - URL of the asset
330
 * @param {function} callback - callback to call with (err, {data})
331
 */
332
const getRemoteAsset = (url, callback) => {
1✔
333
    webRequest(
6✔
334
        {
335
            url,
336
            encoding: null,
337
            gzip: true,
338
        },
339
        (err, res, data) => {
340
            if (err) {
6✔
341
                return callback(err)
1✔
342
            }
343

344
            switch (res.statusCode) {
5✔
345
                case 200: {
346
                    return callback(null, { data })
3✔
347
                }
348
                default: {
349
                    return callback(
2✔
350
                        new Error(
351
                            `request for remote asset failed: ${res.request.uri.href} (status: ${res.statusCode})`
352
                        )
353
                    )
354
                }
355
            }
356
        }
357
    )
358
}
359

360
/**
361
 * Fetch a remotely hosted asset: glyph, sprite, etc
362
 * Anything other than a HTTP 200 response results in an exception.
363
 *
364
 * @param {String} url - URL of the asset
365
 * returns a Promise
366
 */
367
const getRemoteAssetPromise = (url) => {
1✔
368
    return new Promise((resolve, reject) => {
3✔
369
        getRemoteAsset(url, (err, data) => {
3✔
370
            if (err) {
3✔
371
                return reject(err)
1✔
372
            }
373
            return resolve(data)
2✔
374
        })
375
    })
376
}
377

378
/**
379
 * requestHandler constructs a request handler for the map to load resources.
380
 *
381
 * @param {String} - path to tilesets (optional)
382
 * @param {String} - Mapbox GL token (optional; required for any Mapbox hosted resources)
383
 */
384
const requestHandler =
385
    (tilePath, token) =>
1✔
386
    ({ url, kind }, callback) => {
21✔
387
        const isMapbox = isMapboxURL(url)
70✔
388
        if (isMapbox && !token) {
70!
389
            return callback(new Error('mapbox access token is required'))
×
390
        }
391

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

473
/**
474
 * Load an icon image from base64 data or a URL and add it to the map.
475
 *
476
 * @param {Object} map - Mapbox GL map object
477
 * @param {String} id - id of image to add
478
 * @param {Object} options - options object with {url, pixelRatio, sdf}.  url is required
479
 */
480
const loadImage = async (map, id, { url, pixelRatio = 1, sdf = false }) => {
1✔
481
    if (!url) {
7✔
482
        throw new Error(`Invalid url for image: ${id}`)
1✔
483
    }
484

485
    try {
6✔
486
        let imgBuffer = null
6✔
487
        if (url.startsWith('data:')) {
6✔
488
            imgBuffer = Buffer.from(url.split('base64,')[1], 'base64')
3✔
489
        } else {
490
            const img = await getRemoteAssetPromise(url)
3✔
491
            imgBuffer = img.data
2✔
492
        }
493
        const img = sharp(imgBuffer)
5✔
494
        const metadata = await img.metadata()
5✔
495
        const data = await img.raw().toBuffer()
4✔
496
        await map.addImage(id, data, {
4✔
497
            width: metadata.width,
498
            height: metadata.height,
499
            pixelRatio,
500
            sdf,
501
        })
502
    } catch (e) {
503
        throw new Error(`Error loading icon image: ${id}\n${e}`)
2✔
504
    }
505
}
506

507
/**
508
 * Load all icon images to the map.
509
 * @param {Object} map - Mapbox GL map object
510
 * @param {Object} images - object with {id: {url, ...other image properties}}
511
 */
512
const loadImages = async (map, images) => {
1✔
513
    if (images !== null) {
21✔
514
        const imageRequests = Object.entries(images).map(async (image) => {
5✔
515
            await loadImage(map, ...image)
7✔
516
        })
517

518
        // await for all requests to complete
519
        await Promise.all(imageRequests)
5✔
520
    }
521
}
522

523
/**
524
 * Render the map, returning a Promise.
525
 *
526
 * @param {Object} map - Mapbox GL map object
527
 * @param {Object} options - Mapbox GL map options
528
 * @returns
529
 */
530
const renderMap = (map, options) => {
1✔
531
    return new Promise((resolve, reject) => {
18✔
532
        map.render(options, (err, buffer) => {
18✔
533
            if (err) {
18✔
534
                return reject(err)
2✔
535
            }
536
            return resolve(buffer)
16✔
537
        })
538
    })
539
}
540

541
/**
542
 * Convert premultiplied image buffer from Mapbox GL to RGBA PNG format.
543
 * @param {Uint8Array} buffer - image data buffer
544
 * @param {Number} width - image width
545
 * @param {Number} height - image height
546
 * @param {Number} ratio - image pixel ratio
547
 * @returns
548
 */
549
const toPNG = async (buffer, width, height, ratio) => {
1✔
550
    // Un-premultiply pixel values
551
    // Mapbox GL buffer contains premultiplied values, which are not handled correctly by sharp
552
    // https://github.com/mapbox/mapbox-gl-native/issues/9124
553
    // since we are dealing with 8-bit RGBA values, normalize alpha onto 0-255 scale and divide
554
    // it out of RGB values
555

556
    for (let i = 0; i < buffer.length; i += 4) {
16✔
557
        const alpha = buffer[i + 3]
2,212,688✔
558
        const norm = alpha / 255
2,212,688✔
559
        if (alpha === 0) {
2,212,688✔
560
            buffer[i] = 0
398,433✔
561
            buffer[i + 1] = 0
398,433✔
562
            buffer[i + 2] = 0
398,433✔
563
        } else {
564
            buffer[i] /= norm
1,814,255✔
565
            buffer[i + 1] = buffer[i + 1] / norm
1,814,255✔
566
            buffer[i + 2] = buffer[i + 2] / norm
1,814,255✔
567
        }
568
    }
569

570
    return sharp(buffer, {
16✔
571
        raw: {
572
            width: width * ratio,
573
            height: height * ratio,
574
            channels: 4,
575
        },
576
    })
577
        .png()
578
        .toBuffer()
579
}
580

581
/**
582
 * Asynchronously render a map using Mapbox GL, based on layers specified in style.
583
 * Returns PNG image data (via async / Promise).
584
 *
585
 * If zoom and center are not provided, bounds must be provided
586
 * and will be used to calculate center and zoom based on image dimensions.
587
 *
588
 * @param {Object} style - Mapbox GL style object
589
 * @param {number} width - width of output map (default: 1024)
590
 * @param {number} height - height of output map (default: 1024)
591
 * @param {Object} - configuration object containing style, zoom, center: [lng, lat],
592
 * width, height, bounds: [west, south, east, north], ratio, padding
593
 * @param {String} tilePath - path to directory containing local mbtiles files that are
594
 * referenced from the style.json as "mbtiles://<tileset>"
595
 */
596
export const render = async (style, width = 1024, height = 1024, options) => {
1!
597
    const {
598
        bounds = null,
17✔
599
        bearing = 0,
26✔
600
        pitch = 0,
26✔
601
        token = null,
26✔
602
        ratio = 1,
26✔
603
        padding = 0,
20✔
604
        images = null,
22✔
605
    } = options
27✔
606
    let { center = null, zoom = null, tilePath = null } = options
27!
607

608
    if (!style) {
27!
609
        throw new Error('style is a required parameter')
×
610
    }
611
    if (!(width && height)) {
27!
612
        throw new Error(
×
613
            'width and height are required parameters and must be non-zero'
614
        )
615
    }
616

617
    if (center !== null) {
27✔
618
        if (center.length !== 2) {
17!
619
            throw new Error(
×
620
                `Center must be longitude,latitude.  Invalid value found: ${[
621
                    ...center,
622
                ]}`
623
            )
624
        }
625

626
        if (Math.abs(center[0]) > 180) {
17!
627
            throw new Error(
×
628
                `Center longitude is outside world bounds (-180 to 180 deg): ${center[0]}`
629
            )
630
        }
631

632
        if (Math.abs(center[1]) > 90) {
17!
633
            throw new Error(
×
634
                `Center latitude is outside world bounds (-90 to 90 deg): ${center[1]}`
635
            )
636
        }
637
    }
638

639
    if (zoom !== null && (zoom < 0 || zoom > 22)) {
27!
640
        throw new Error(`Zoom level is outside supported range (0-22): ${zoom}`)
×
641
    }
642

643
    if (bearing !== null && (bearing < 0 || bearing > 360)) {
27!
644
        throw new Error(
×
645
            `bearing is outside supported range (0-360): ${bearing}`
646
        )
647
    }
648

649
    if (pitch !== null && (pitch < 0 || pitch > 60)) {
27!
650
        throw new Error(`pitch is outside supported range (0-60): ${pitch}`)
×
651
    }
652

653
    if (bounds !== null) {
27✔
654
        if (bounds.length !== 4) {
10!
655
            throw new Error(
×
656
                `Bounds must be west,south,east,north.  Invalid value found: ${[
657
                    ...bounds,
658
                ]}`
659
            )
660
        }
661

662
        if (padding) {
10✔
663
            // padding must not be greater than width / 2 and height / 2
664
            if (Math.abs(padding) >= width / 2) {
6✔
665
                throw new Error('Padding must be less than width / 2')
2✔
666
            }
667
            if (Math.abs(padding) >= height / 2) {
4✔
668
                throw new Error('Padding must be less than height / 2')
2✔
669
            }
670
        }
671
    }
672

673
    // calculate zoom and center from bounds and image dimensions
674
    if (bounds !== null && (zoom === null || center === null)) {
23!
675
        const viewport = geoViewport.viewport(
6✔
676
            bounds,
677
            // add padding to width and height to effectively
678
            // zoom out the target zoom level.
679
            [width - 2 * padding, height - 2 * padding],
680
            undefined,
681
            undefined,
682
            undefined,
683
            true
684
        )
685
        zoom = Math.max(viewport.zoom - 1, 0)
6✔
686
        /* eslint-disable prefer-destructuring */
687
        center = viewport.center
6✔
688
    }
689

690
    // validate that all local mbtiles referenced in style are
691
    // present in tilePath and that tilePath is not null
692
    if (tilePath) {
23✔
693
        tilePath = path.normalize(tilePath)
13✔
694
    }
695

696
    const localMbtilesMatches = JSON.stringify(style).match(MBTILES_REGEXP)
23✔
697
    if (localMbtilesMatches && !tilePath) {
23✔
698
        throw new Error(
1✔
699
            'Style has local mbtiles file sources, but no tilePath is set'
700
        )
701
    }
702

703
    if (localMbtilesMatches) {
22✔
704
        localMbtilesMatches.forEach((name) => {
9✔
705
            const mbtileFilename = path.normalize(
10✔
706
                path.format({
707
                    dir: tilePath,
708
                    name: name.split('://')[1],
709
                    ext: '.mbtiles',
710
                })
711
            )
712
            if (!fs.existsSync(mbtileFilename)) {
10✔
713
                throw new Error(
1✔
714
                    `Mbtiles file ${path.format({
715
                        name,
716
                        ext: '.mbtiles',
717
                    })} in style file is not found in: ${path.resolve(
718
                        tilePath
719
                    )}`
720
                )
721
            }
722
        })
723
    }
724

725
    const map = new maplibre.Map({
21✔
726
        request: requestHandler(tilePath, token),
727
        ratio,
728
    })
729

730
    map.load(style)
21✔
731

732
    await loadImages(map, images)
21✔
733

734
    const buffer = await renderMap(map, {
18✔
735
        zoom,
736
        center,
737
        height,
738
        width,
739
        bearing,
740
        pitch,
741
    })
742

743
    return toPNG(buffer, width, height, ratio)
16✔
744
}
745

746
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