@mapka/sprites-generate 0.4.1

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.
@@ -0,0 +1,336 @@
1
+ import ShelfPack from "@mapka/shelf-pack";
2
+ import mapnik from "@mapnik/mapnik";
3
+ import { extractSvgMetadata } from "./metadata.js";
4
+ import type { DataLayout, DataSvgImage, ImgLayout, SvgImage } from "./types.js";
5
+ import type { Metadata } from "./validate.js";
6
+
7
+ interface Item {
8
+ height: number;
9
+ width: number;
10
+ id: string;
11
+ }
12
+
13
+ function heightAscThanNameComparator(a: Item, b: Item) {
14
+ return b.height - a.height || (a.id === b.id ? 0 : a.id < b.id ? -1 : 1);
15
+ }
16
+
17
+ function isNotNull<T>(value: T | null): value is T {
18
+ return value !== null
19
+ }
20
+
21
+ export interface Img {
22
+ svg: Buffer;
23
+ id: string;
24
+ }
25
+
26
+ export interface GenerateLayoutOptions {
27
+ /**
28
+ * Overrides the max_size in mapnik
29
+ */
30
+ maxIconSize?: number;
31
+ /**
32
+ * If set, filters out icons that mapnik says are too big
33
+ */
34
+ removeOversizedIcons?: boolean;
35
+
36
+ /**
37
+ * If set, filters out icons that have no height or width attribute set
38
+ * @default true
39
+ */
40
+ removeSizeMissingIcons?: boolean;
41
+
42
+ /**
43
+ * If false, omits stretch and placeholder metadata (`content`, `stretchX`, `stretchY`, `placeholder`) in {@link DataLayout}
44
+ * @default true
45
+ */
46
+ extractMetadata?: boolean;
47
+ /**
48
+ * If true, generate {@link DataLayout} as first argument to callback and {@link ImgLayout} as the second argument; if false, generate only {@link ImgLayout} as the first argument to the callback function
49
+ */
50
+ format: boolean;
51
+ pixelRatio: number;
52
+ imgs: Img[];
53
+ }
54
+
55
+ export type LayoutResult<T extends GenerateLayoutOptions> = T["format"] extends true
56
+ ? DataLayout
57
+ : ImgLayout;
58
+
59
+
60
+ const oversizedRe = /image created from svg must be \d+ pixels or fewer on each side/;
61
+ const emptyRe = /image created from svg must have a width and height greater (then|than) zero/;
62
+
63
+ type NotPackedShelfPacked<T extends {x: number, y: number}> = Omit<T, 'x' | 'y'>;
64
+
65
+ /**
66
+ * Create an image from an svg data
67
+ */
68
+ function createImage(
69
+ img: Img,
70
+ options: GenerateLayoutOptions,
71
+ ): Promise<NotPackedShelfPacked<SvgImage> | NotPackedShelfPacked<DataSvgImage> | null> {
72
+ const { svg, id } = img;
73
+ const {
74
+ pixelRatio,
75
+ maxIconSize,
76
+ removeOversizedIcons,
77
+ removeSizeMissingIcons,
78
+ extractMetadata,
79
+ format
80
+ } = options;
81
+
82
+ const svgImageOptions: { scale: number, max_size?: number } = {
83
+ scale: pixelRatio,
84
+ };
85
+ if (typeof maxIconSize === 'number') {
86
+ svgImageOptions.max_size = maxIconSize;
87
+ }
88
+
89
+ return new Promise<NotPackedShelfPacked<SvgImage> | NotPackedShelfPacked<DataSvgImage> | null>((resolve, reject) => {
90
+ mapnik.Image.fromSVGBytes(
91
+ svg,
92
+ svgImageOptions,
93
+ (err, image) => {
94
+ if (err) {
95
+ // Produce null result if image was too big and removeOversizedIcons is true
96
+ // Check for specific error message to avoid false positive
97
+ if (
98
+ removeOversizedIcons && err.message.match(oversizedRe)) {
99
+ return resolve(null);
100
+ }
101
+ // Produce a null result if no width or height attributes and removeSizeMissingIcons is true
102
+ // The error message from mapnik has a typo "then"; account for potential future fix to "than".
103
+ if (removeSizeMissingIcons && err.message.match(emptyRe)) {
104
+ return resolve(null);
105
+ }
106
+ // Any other error is a real error
107
+ return reject(err);
108
+ }
109
+ const height = image.height();
110
+ const width = image.width();
111
+
112
+ if (!width || !height) {
113
+ return resolve(null);
114
+ }
115
+
116
+ // For the data layout JSON (when options.format is true), extract stretch and placeholder metadata if present
117
+ // In addition to mapka-stretch and mapka-text-placeholder, also check for mapbox-stretch and mapbox-text-placeholder
118
+ if (format && extractMetadata) {
119
+ if (
120
+ svg.includes("mapbox-stretch") ||
121
+ svg.includes("mapbox-text-placeholder") ||
122
+ svg.includes("mapka-stretch") ||
123
+ svg.includes("mapka-text-placeholder")
124
+ ) {
125
+ extractSvgMetadata({ svg, pixelRatio }, (err: Error | null, metadata: Metadata) => {
126
+ if (err) return reject(err);
127
+
128
+ return resolve({
129
+ svg,
130
+ id,
131
+ width,
132
+ height,
133
+ buffer: image,
134
+ ...metadata,
135
+ });
136
+ });
137
+ return;
138
+ }
139
+ }
140
+ return resolve({
141
+ svg,
142
+ id,
143
+ width,
144
+ height,
145
+ buffer: image,
146
+ });
147
+ },
148
+ );
149
+ });
150
+ }
151
+
152
+ async function createImages(options: GenerateLayoutOptions) {
153
+ const images = await Promise.all(options.imgs.map((img) => createImage(img, options)));
154
+
155
+ // Remove nulls because of removeSizeMissingIcons, removeOversizedIcons
156
+ const nonNullImages = images.filter(isNotNull);
157
+
158
+ // Sort images by height ascending and by id ascending
159
+ const sortedImages = nonNullImages.toSorted(heightAscThanNameComparator);
160
+
161
+ return sortedImages;
162
+ }
163
+
164
+ function normalizeOptions<T extends GenerateLayoutOptions>(options: T): T {
165
+ const {
166
+ extractMetadata = true,
167
+ removeSizeMissingIcons = true,
168
+ pixelRatio,
169
+ imgs = []
170
+ } = options;
171
+
172
+ if (!Array.isArray(imgs)) {
173
+ throw new Error("imgs must be an array");
174
+ }
175
+ if (!Number.isInteger(pixelRatio)) {
176
+ throw new Error("pixelRatio must be a number");
177
+ }
178
+
179
+ return {
180
+ ...options,
181
+ pixelRatio,
182
+ extractMetadata,
183
+ removeSizeMissingIcons,
184
+ imgs,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Pack a list of images with width and height into a sprite layout.
190
+ * options object with the following keys:
191
+ * Generated Layout Object with sprite contents
192
+ */
193
+ export async function generateLayout<T extends GenerateLayoutOptions>(
194
+ options: T,
195
+ ): Promise<LayoutResult<T>> {
196
+ const normalizedOptions = normalizeOptions(options);
197
+
198
+ // Create images from all svg images using same options
199
+ const allImages = await createImages(normalizedOptions);
200
+
201
+ /**
202
+ * Create a ShelfPack instance with autoResize = true
203
+ * Pack all images into the sprite
204
+ * Packing will mutate the images in place and add `x` and `y` properties
205
+ */
206
+ const sprite = new ShelfPack(1, 1, { autoResize: true });
207
+ sprite.pack(allImages, {
208
+ inPlace: true
209
+ });
210
+
211
+ if (normalizedOptions.format === false) {
212
+ return {
213
+ width: sprite.w,
214
+ height: sprite.h,
215
+ items: allImages as SvgImage[]
216
+ } as LayoutResult<T>;
217
+ }
218
+
219
+ if (normalizedOptions.format === true) {
220
+ const dataLayout: DataLayout = {};
221
+ (allImages as DataSvgImage[]).forEach((item) => {
222
+ dataLayout[item.id] = {
223
+ width: item.width,
224
+ height: item.height,
225
+ x: item.x,
226
+ y: item.y,
227
+ pixelRatio: options.pixelRatio
228
+ };
229
+
230
+ if (item.placeholder) {
231
+ dataLayout[item.id].placeholder = item.placeholder;
232
+ }
233
+ if (item.content) {
234
+ dataLayout[item.id].content = item.content;
235
+ }
236
+ if (item.stretchX) {
237
+ dataLayout[item.id].stretchX = item.stretchX;
238
+ }
239
+ if (item.stretchY) {
240
+ dataLayout[item.id].stretchY = item.stretchY;
241
+ }
242
+ });
243
+ return dataLayout as LayoutResult<T>;
244
+ }
245
+
246
+ throw new Error("Invalid format");
247
+ }
248
+
249
+ /**
250
+ * Same as generateLayout but can be used to dedupe identical SVGs
251
+ * and still preserve the reference.
252
+ *
253
+ * For example if `A.svg` and `B.svg` are identical, a single icon
254
+ * will be in the sprite image and both A and B will reference the same image
255
+ *
256
+ */
257
+ export async function generateLayoutUnique<T extends GenerateLayoutOptions>(
258
+ options: T,
259
+ ): Promise<LayoutResult<T>> {
260
+ const normalizedOptions = normalizeOptions(options);
261
+
262
+ /* The svg signature of each item */
263
+ const svgPerItemId: Record<string, string> = {};
264
+
265
+ /* The items for each SVG signature */
266
+ const itemIdsPerSvg: Record<string, string[]> = {};
267
+
268
+ normalizedOptions.imgs.forEach((item) => {
269
+ var svg = item.svg.toString("base64");
270
+
271
+ svgPerItemId[item.id] = svg;
272
+
273
+ if (svg in itemIdsPerSvg) {
274
+ itemIdsPerSvg[svg].push(item.id);
275
+ } else {
276
+ itemIdsPerSvg[svg] = [item.id];
277
+ }
278
+ });
279
+
280
+ /* Only keep 1 item per svg signature for packing */
281
+ normalizedOptions.imgs = normalizedOptions.imgs.filter((item) => {
282
+ var svg = svgPerItemId[item.id];
283
+ return item.id === itemIdsPerSvg[svg][0];
284
+ });
285
+
286
+ const allImages = await createImages(normalizedOptions);
287
+
288
+ /**
289
+ * Create a ShelfPack instance with autoResize = true
290
+ * Pack all images into the sprite
291
+ * Packing will mutate the images in place and add `x` and `y` properties
292
+ */
293
+ const sprite = new ShelfPack(1, 1, { autoResize: true });
294
+ sprite.pack(allImages, {
295
+ inPlace: true
296
+ });
297
+
298
+ if (normalizedOptions.format) {
299
+ const dataLayout: DataLayout = {};
300
+ (allImages as DataSvgImage[]).forEach((item) => {
301
+ const svg = svgPerItemId[item.id];
302
+ const itemIdsToUpdate = itemIdsPerSvg[svg];
303
+
304
+ itemIdsToUpdate.forEach((itemIdToUpdate) => {
305
+ dataLayout[itemIdToUpdate] = {
306
+ width: item.width,
307
+ height: item.height,
308
+ x: item.x,
309
+ y: item.y,
310
+ pixelRatio: options.pixelRatio
311
+ };
312
+
313
+ if (item.placeholder) {
314
+ dataLayout[itemIdToUpdate].placeholder = item.placeholder;
315
+ }
316
+ if (item.content) {
317
+ dataLayout[itemIdToUpdate].content = item.content;
318
+ }
319
+ if (item.stretchX) {
320
+ dataLayout[itemIdToUpdate].stretchX = item.stretchX;
321
+ }
322
+ if (item.stretchY) {
323
+ dataLayout[itemIdToUpdate].stretchY = item.stretchY;
324
+ }
325
+ });
326
+ });
327
+ return dataLayout as LayoutResult<T>;
328
+ }
329
+
330
+ return {
331
+ width: sprite.w,
332
+ height: sprite.h,
333
+ items: allImages as SvgImage[]
334
+ } as LayoutResult<T>;
335
+ }
336
+
package/src/image.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { Buffer } from "node:buffer";
2
+ import mapnik from "@mapnik/mapnik";
3
+ import type { ImgLayout } from "./types.js";
4
+
5
+ const emptyPNG = new mapnik.Image(1, 1).encodeSync("png");
6
+
7
+ /**
8
+ * Generate a PNG image with positioned icons on a sprite.
9
+ */
10
+ export function generateImage(layout: ImgLayout): Promise<Buffer> {
11
+ if (typeof layout !== "object") {
12
+ throw new Error("layout must be an object");
13
+ }
14
+
15
+ if (!layout.items.length) return Promise.resolve(emptyPNG);
16
+
17
+ const {
18
+ width,
19
+ height,
20
+ items,
21
+ } = layout;
22
+
23
+ return new Promise<Buffer>((resolve, reject) => {
24
+ mapnik.blend(
25
+ items,
26
+ {
27
+ width,
28
+ height,
29
+ },
30
+ (err?: Error | null, image?: Buffer) => {
31
+ if (err) return reject(err);
32
+ if (!image) return reject();
33
+
34
+ resolve(image);
35
+ },
36
+ );
37
+ });
38
+ }
39
+
40
+ interface OptimizationOptions {
41
+ quality?: number;
42
+ }
43
+
44
+ /**
45
+ * Generate a PNG image with positioned icons on a sprite.
46
+ *
47
+ */
48
+ export function generateOptimizedImage(layout: ImgLayout, {quality = 128}: OptimizationOptions): Promise<Buffer> {
49
+ if (typeof layout !== "object") {
50
+ throw new Error("layout must be an object");
51
+ }
52
+
53
+ if (!layout.items.length) return Promise.resolve(emptyPNG);
54
+
55
+ const {
56
+ width,
57
+ height,
58
+ items,
59
+ } = layout;
60
+
61
+
62
+ return new Promise<Buffer>((resolve, reject) => {
63
+ mapnik.blend(
64
+ items,
65
+ {
66
+ format: "png",
67
+ mode: "hextree",
68
+ quality,
69
+ width,
70
+ height,
71
+ },
72
+ (err?: Error | null, image?: Buffer) => {
73
+ if (err) return reject(err);
74
+ if (!image) return reject();
75
+
76
+ resolve(image);
77
+ },
78
+ );
79
+ });
80
+
81
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {generateLayout, generateLayoutUnique} from './generate.js';
2
+ export {generateImage, generateOptimizedImage} from './image.js';
3
+
4
+ export {extractSvgMetadata} from './metadata.js';
5
+ export {validateMetadata} from './validate.js';
@@ -0,0 +1,193 @@
1
+ import { resolvePluginConfig } from 'svgo/lib/svgo/config.js';
2
+ import { parseSvg } from 'svgo/lib/parser.js';
3
+ import { stringifySvg } from 'svgo/lib/stringifier.js';
4
+ import { invokePlugins } from 'svgo/lib/svgo/plugins.js';
5
+ import svgBoundings from 'svg-boundings';
6
+
7
+ const computePathBounds = svgBoundings.path;
8
+
9
+ const metadataPrefix = 'mapbox-';
10
+
11
+ // Removes `style` attribute. Otherwise, transforms will not apply.
12
+ // Refer to https://github.com/svg/svgo/blob/a9834efa1603198ae606765292215462ae193d0d/plugins/applyTransforms.js#L55-L57
13
+ const removeStyleAttribute = {
14
+ name: 'removeStyleAttribute',
15
+ type: 'perItem',
16
+ fn: (item) => {
17
+ if (item.hasAttr('style'))
18
+ item.removeAttr('style');
19
+ return item;
20
+ }
21
+ };
22
+
23
+ // Transforms `id="mapbox-*"` into `mapbox:metadata="*"` so that the `convertPathData` step
24
+ // resolves transforms.
25
+ const renameMapboxMetadata = {
26
+ name: 'renameMapboxMetadata',
27
+ type: 'perItem',
28
+ fn: function(item) {
29
+ if (item.hasAttr('id')) {
30
+ const id = item.attr('id');
31
+ if (id.value.startsWith(metadataPrefix)) {
32
+ let valueSubstr = id.value.substring(metadataPrefix.length);
33
+ item.removeAttr('id');
34
+ item.addAttr({
35
+ name: 'mapbox:metadata',
36
+ value: valueSubstr,
37
+ prefix: '',
38
+ local: 'mapbox:metadata',
39
+ });
40
+ }
41
+ }
42
+
43
+ return item;
44
+ }
45
+ };
46
+
47
+ /**
48
+ * Decrease accuracy of floating-point numbers
49
+ * in path data keeping a specified number of decimals.
50
+ * Smart rounds values like 2.3491 to 2.35 instead of 2.349.
51
+ * Doesn't apply "smartness" if the number precision fits already.
52
+ *
53
+ * Taken from svgo at https://github.com/svg/svgo/blob/72db8eb/plugins/convertPathData.js#L773
54
+ */
55
+ function strongRound(value, precision) {
56
+ if (!precision) {
57
+ precision = 3;
58
+ }
59
+ const error = +Math.pow(0.1, precision).toFixed(precision);
60
+ if (+value.toFixed(precision) !== value) {
61
+ var rounded = +value.toFixed(precision - 1);
62
+ value = +Math.abs(rounded - value).toFixed(precision + 1) >= error ?
63
+ +value.toFixed(precision) :
64
+ rounded;
65
+ }
66
+ return value;
67
+ }
68
+
69
+ // Plugin for use with svgo that extracts metadata from paths.
70
+ const extractMapboxMetadata = {
71
+ name: 'extractMapboxMetadata',
72
+ type: 'perItem',
73
+ fn: function (item, params, info) {
74
+ if (item.isElem('path') && item.hasAttr('mapbox:metadata') && item.hasAttr('d')) {
75
+ const metadata = item.attr('mapbox:metadata');
76
+ const bounds = computePathBounds({ d: item.attr('d').value }, true);
77
+
78
+ // Make sure that we actually have a valid bounding box.
79
+ if (!Number.isFinite(bounds.left) || !Number.isFinite(bounds.right) ||
80
+ !Number.isFinite(bounds.top) || !Number.isFinite(bounds.bottom)) {
81
+ return true;
82
+ }
83
+
84
+ // Scale bounds by pixel ratio.
85
+ bounds.left = strongRound(bounds.left * info.pixelRatio);
86
+ bounds.top = strongRound(bounds.top * info.pixelRatio);
87
+ bounds.right = strongRound(bounds.right * info.pixelRatio);
88
+ bounds.bottom = strongRound(bounds.bottom * info.pixelRatio);
89
+
90
+ if (metadata.value.startsWith('stretch-x')) {
91
+ (info.metadata.stretchX = info.metadata.stretchX || []).push([bounds.left, bounds.right]);
92
+ } else if (metadata.value.startsWith('stretch-y')) {
93
+ (info.metadata.stretchY = info.metadata.stretchY || []).push([bounds.top, bounds.bottom]);
94
+ } else if (metadata.value === 'stretch') {
95
+ (info.metadata.stretchX = info.metadata.stretchX || []).push([bounds.left, bounds.right]);
96
+ (info.metadata.stretchY = info.metadata.stretchY || []).push([bounds.top, bounds.bottom]);
97
+ } else if (metadata.value === 'content' || metadata.value === 'text-placeholder') {
98
+ const key = metadata.value === 'content' ? 'content' : 'placeholder';
99
+ info.metadata[key] = [bounds.left, bounds.top, bounds.right, bounds.bottom];
100
+ }
101
+ }
102
+
103
+ return true;
104
+ }
105
+ };
106
+
107
+ const preparePluginsConfig = [
108
+ {
109
+ name: 'cleanupIDs',
110
+ params: {
111
+ // Remove all IDs first so that `convertPathData` resolves transforms.
112
+ preservePrefixes: ['mapbox-']
113
+ }
114
+ },
115
+ renameMapboxMetadata,
116
+ 'convertShapeToPath'
117
+ ].map(resolvePluginConfig);
118
+
119
+ const collapsePluginsConfig = [
120
+ 'convertTransform',
121
+ 'moveGroupAttrsToElems',
122
+ 'collapseGroups'
123
+ ].map(resolvePluginConfig);
124
+
125
+ const extractPluginsConfig = [
126
+ removeStyleAttribute,
127
+ {
128
+ name: 'convertPathData',
129
+ params: {
130
+ // svg-boundings has trouble parsing consecutive numbers without space and without a leading
131
+ // zero. Therefore, we force leading zeros. Note that this option confusingly has to be set
132
+ // to _false_ to produce leading zeros.
133
+ // See https://github.com/kfitfk/svg-boundings/issues/2
134
+ leadingZero: false
135
+ }
136
+ },
137
+ extractMapboxMetadata
138
+ ].map(resolvePluginConfig);
139
+
140
+ /**
141
+ * Parses a SVG document and extracts metadata from its shapes and paths.
142
+ *
143
+ * @param {Object} [img]
144
+ * @param {Buffer|string} [img.svg] A string of the SVG.
145
+ * @param {number} [img.pixelRatio] Ratio of a 72dpi screen pixel to the destination pixel density
146
+ * @param {Function} callback Accepts two arguments, `err` and `metadata` Object
147
+ * @return {Metadata} metadata An object with the extracted information.
148
+ */
149
+ export function extractSvgMetadata(img, callback) {
150
+ try {
151
+ const imgSvg = Buffer.isBuffer(img.svg) ? img.svg.toString('utf-8') : img.svg;
152
+
153
+ let result = parseSvg(imgSvg);
154
+
155
+ // We'll add the metadata that we extract in the svgo plugin above to this object.
156
+ const info = {
157
+ metadata: {},
158
+ pixelRatio: Number.isFinite(img.pixelRatio) && img.pixelRatio || 1
159
+ };
160
+
161
+ let previousSVG;
162
+ let svg = img.svg;
163
+
164
+ try {
165
+ // A few initial plugins prepare the data, e.g. by moving IDs to custom attributes.
166
+ result = invokePlugins(result, info, preparePluginsConfig);
167
+
168
+ // We'll walk through the plugins that collapse groups until there are no changes to the output.
169
+ do {
170
+ previousSVG = svg;
171
+ result = invokePlugins(result, info, collapsePluginsConfig);
172
+ svg = stringifySvg(result).data;
173
+ } while (previousSVG !== svg);
174
+
175
+ // Finally, we'll run through the plugins that actually read and parse the metadata.
176
+ invokePlugins(result, info, extractPluginsConfig);
177
+ } catch (err) {
178
+ return callback(err);
179
+ }
180
+
181
+ // Sort stretches ascending.
182
+ for (const key of ['stretchX', 'stretchY']) {
183
+ if (info.metadata[key]) {
184
+ info.metadata[key].sort((a, b) => a[0] - b[0]);
185
+ }
186
+ }
187
+
188
+ callback(null, info.metadata);
189
+ } catch (err) {
190
+ return callback(err);
191
+ }
192
+ }
193
+
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { Image } from "@mapnik/mapnik";
2
+ import type { Metadata } from "./validate.js";
3
+
4
+ export interface SvgImage {
5
+ svg: Buffer;
6
+ id: string;
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ buffer: Image;
12
+ }
13
+
14
+ export interface DataSvgImage extends Metadata {
15
+ svg: Buffer;
16
+ id: string;
17
+ width: number;
18
+ height: number;
19
+ buffer: Image;
20
+ }
21
+
22
+ export interface DataLayout {
23
+ [key: string]: {
24
+ width: number;
25
+ height: number;
26
+ x: number;
27
+ y: number;
28
+ pixelRatio: number;
29
+ content?: [number, number, number, number];
30
+ stretchX?: [number, number][];
31
+ stretchY?: [number, number][];
32
+ placeholder?: [number, number, number, number];
33
+ };
34
+ }
35
+
36
+ export interface ImgLayout {
37
+ width: number;
38
+ height: number;
39
+ items: SvgImage[];
40
+ }