@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.
- package/LICENSE +17 -0
- package/README.md +25 -0
- package/lib/.buildInfo.json +1 -0
- package/lib/__bench__/generate.bench.d.ts +2 -0
- package/lib/__bench__/generate.bench.d.ts.map +1 -0
- package/lib/__bench__/generate.bench.js +27 -0
- package/lib/__bench__/generate.bench.js.map +1 -0
- package/lib/generate.d.ts +48 -0
- package/lib/generate.d.ts.map +1 -0
- package/lib/generate.js +227 -0
- package/lib/generate.js.map +1 -0
- package/lib/image.d.ts +16 -0
- package/lib/image.d.ts.map +1 -0
- package/lib/image.js +53 -0
- package/lib/image.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -0
- package/lib/metadata.d.ts +14 -0
- package/lib/metadata.d.ts.map +1 -0
- package/lib/metadata.js +175 -0
- package/lib/metadata.js.map +1 -0
- package/lib/types.d.ts +37 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/validate.d.ts +20 -0
- package/lib/validate.d.ts.map +1 -0
- package/lib/validate.js +65 -0
- package/lib/validate.js.map +1 -0
- package/package.json +30 -0
- package/src/__bench__/generate.bench.ts +30 -0
- package/src/declarations.d.ts +71 -0
- package/src/generate.ts +336 -0
- package/src/image.ts +81 -0
- package/src/index.ts +5 -0
- package/src/metadata.js +193 -0
- package/src/types.ts +40 -0
- package/src/validate.ts +92 -0
package/src/generate.ts
ADDED
|
@@ -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
package/src/metadata.js
ADDED
|
@@ -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
|
+
}
|