Skip to content

Commit 37fcc19

Browse files
authored
feat(sprites): support non svg sprites (#2736)
* feat(sprites): support non svg sprites adds support for `--extension .png` to load png (or any other format that libvips supports) as sprites * refactor: add missing test sprite
1 parent 1b00735 commit 37fcc19

File tree

9 files changed

+137
-47
lines changed

9 files changed

+137
-47
lines changed

packages/sprites/README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { basename } from 'path';
1212

1313
const sprites: SvgId[] = [];
1414
for await (const spritePath of fsa.list('./config/sprites')) {
15-
sprites.push({ id: basename(spritePath).replace('.svg', ''), svg: await fsa.read(spritePath) });
15+
if (!spritePath.endsWith('.svg')) continue;
16+
sprites.push({ id: basename(spritePath).replace('.svg', ''), buffer: await fsa.read(spritePath) });
1617
}
1718

1819
const generated = await Sprites.generate(sprites, [1, 2, 4]);
@@ -41,4 +42,14 @@ topographic.json
4142
topographic.png
4243

4344
topographic@2x.json
44-
topographic@2x.png
45+
topographic@2x.png
46+
47+
Sprites can also be in other formats such as PNG or WebP
48+
49+
```
50+
# Load only png sprites
51+
basemaps-sprites --extension .png ./config/sprites/topographic
52+
53+
# Load png, webp and svg sprites
54+
basemaps-sprites --extension .png --extension .svg --extension .webp ./config/sprites/topographic
55+
```
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#!/usr/bin/env node
22

33
import { SpriteCli } from '../build/cli.js';
4+
import { run } from 'cmd-ts';
45

5-
new SpriteCli().execute();
6+
run(SpriteCli, process.argv.slice(2)).catch((err) => {
7+
console.error({ err }, 'Command:Failed');
8+
});

packages/sprites/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
],
3434
"dependencies": {
3535
"@mapbox/shelf-pack": "^3.2.0",
36-
"@rushstack/ts-command-line": "^4.3.13",
36+
"cmd-ts": "^0.12.1",
3737
"sharp": "^0.30.7"
3838
},
3939
"devDependencies": {

packages/sprites/src/__test__/readme.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { basename } from 'path';
66
export async function main(): Promise<void> {
77
const sprites: SvgId[] = [];
88
for await (const spritePath of fsa.list('./config/sprites')) {
9-
sprites.push({ id: basename(spritePath).replace('.svg', ''), svg: await fsa.read(spritePath) });
9+
sprites.push({ id: basename(spritePath).replace('.svg', ''), buffer: await fsa.read(spritePath) });
1010
}
1111

1212
const generated = await Sprites.generate(sprites, [1, 2, 4]);

packages/sprites/src/__test__/sprite.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ import { createHash } from 'crypto';
22
import o from 'ospec';
33
import { dirname, join } from 'path';
44
import { fileURLToPath } from 'url';
5-
import { listSprites } from '../fs.js';
5+
import { listSprites, ValidExtensions } from '../fs.js';
66
import { Sprites } from '../sprites.js';
77

88
o.spec('Sprites', () => {
99
const __dirname = dirname(fileURLToPath(import.meta.url));
1010

11+
o.beforeEach(() => {
12+
ValidExtensions.clear();
13+
ValidExtensions.add('.svg');
14+
});
15+
1116
o.specTimeout(2_500);
1217
o('should generate sprites from examples', async () => {
1318
const baseSprites = join(__dirname, '../../static/sprites');
@@ -31,4 +36,40 @@ o.spec('Sprites', () => {
3136
const hashB = createHash('sha256').update(res[1].buffer).digest('base64url');
3237
o(hashB).equals('kM-6X4tpLicvxm1rnIDZq4vultMG5pDutRczJd2MteE');
3338
});
39+
40+
o('should generate sprites from from examples including images', async () => {
41+
const baseSprites = join(__dirname, '../../static/sprites');
42+
43+
ValidExtensions.clear();
44+
ValidExtensions.add('.png');
45+
46+
const files = await listSprites(baseSprites);
47+
const res = await Sprites.generate(files, [1, 2]);
48+
49+
o(res[0].layout).deepEquals({
50+
circle: { width: 17, height: 17, x: 0, y: 0, pixelRatio: 1 },
51+
});
52+
const hashA = createHash('sha256').update(res[0].buffer).digest('base64url');
53+
o(hashA).equals('pYs-QfTjaCURzq7MDk1CtYvIEVtoW9dAGwq5hwZil2g');
54+
});
55+
56+
o('should support both svg and png sprites in one image', async () => {
57+
const baseSprites = join(__dirname, '../../static/sprites');
58+
59+
ValidExtensions.clear();
60+
ValidExtensions.add('.png');
61+
ValidExtensions.add('.svg');
62+
63+
const files = await listSprites(baseSprites);
64+
const res = await Sprites.generate(files, [1, 2]);
65+
66+
o(res[0].layout).deepEquals({
67+
embankment_no_gap_cl_thick_wide: { width: 64, height: 32, x: 0, y: 0, pixelRatio: 1 },
68+
circle: { width: 17, height: 17, x: 64, y: 0, pixelRatio: 1 },
69+
airport_aerodrome_pnt_fill: { width: 16, height: 16, x: 81, y: 0, pixelRatio: 1 },
70+
mast_pnt: { width: 16, height: 16, x: 97, y: 0, pixelRatio: 1 },
71+
});
72+
const hashA = createHash('sha256').update(res[0].buffer).digest('base64url');
73+
o(hashA).equals('kVGL6hInF6b1i-2DZYY-TDHGF9HFloOTT5ARaN0sdqA');
74+
});
3475
});

packages/sprites/src/cli.ts

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,65 @@
1-
import { CommandLineParser } from '@rushstack/ts-command-line';
1+
/* eslint-disable no-console */
2+
import { command, multioption, flag, number, restPositionals, array, string } from 'cmd-ts';
23
import { writeFile } from 'fs/promises';
34
import path from 'path';
4-
import { listSprites } from './fs.js';
5+
import { listSprites, ValidExtensions } from './fs.js';
56
import { Sprites } from './sprites.js';
67
import { promises as fs } from 'fs';
78

8-
export class SpriteCli extends CommandLineParser {
9-
ratio = this.defineIntegerListParameter({
10-
argumentName: 'RATIO',
11-
parameterLongName: '--ratio',
12-
description: 'Pixel ratio, default: "--ratio 1 --ratio 2"',
13-
});
9+
export const SpriteCli = command({
10+
name: 'basemaps-sprites',
11+
description: 'Create a sprite sheet from a folder of sprites',
12+
args: {
13+
ratio: multioption({ long: 'ratio', type: array(number), description: 'Pixel ratios to use, default: 1, 2' }),
14+
retina: flag({ long: 'retina', defaultValue: () => false, description: 'Double the pixel ratio' }),
15+
paths: restPositionals({ description: 'Path to sprites' }),
16+
extensions: multioption({
17+
long: 'extension',
18+
type: array(string),
19+
description: 'File extensions to use, default: .svg',
20+
}),
21+
},
22+
handler: async (args) => {
23+
if (args.paths.length === 0) throw new Error('No sprite paths supplied');
24+
if (args.ratio.length === 0) args.ratio.push(1, 2);
1425

15-
retina = this.defineFlagParameter({
16-
parameterLongName: '--retina',
17-
description: 'Double the pixel ratios, 1x becomes 2x',
18-
});
19-
r = this.defineCommandLineRemainder({ description: 'Path to sprites' });
26+
if (args.extensions.length > 0) ValidExtensions.clear();
27+
for (const ext of args.extensions) {
28+
const extName = ext.toLowerCase();
29+
if (extName.startsWith('.')) ValidExtensions.add(extName);
30+
else ValidExtensions.add(`.${extName}`);
31+
}
2032

21-
constructor() {
22-
super({
23-
toolFilename: 'basemaps-sprites',
24-
toolDescription: 'Create a sprite sheet from a folder of sprites',
25-
});
26-
}
33+
const result = await buildSprites(args.ratio, args.retina, args.paths);
2734

28-
protected onDefineParameters(): void {
29-
// Noop
30-
}
31-
32-
protected async onExecute(): Promise<void> {
33-
if (this.remainder?.values == null || this.remainder.values.length === 0) {
34-
throw new Error('No sprite paths supplied');
35+
for (const r of result) {
36+
console.log('Write', r.sprites, 'sprites to', r.path, { ratio: r.ratio });
3537
}
36-
const ratio = [...this.ratio.values];
37-
const paths = [...this.remainder.values];
38-
await buildSprites(ratio, this.retina.value, paths);
39-
}
40-
}
38+
console.log('Done');
39+
},
40+
});
4141

42-
export async function buildSprites(ratio: number[], retina: boolean, paths: string[], output?: string): Promise<void> {
42+
export interface SpriteStats {
43+
/** Sprite sheet name */
44+
sheet: string;
45+
/** Number of sprites found */
46+
sprites: number;
47+
/** Pixel ratio */
48+
ratio: number;
49+
/** Output location */
50+
path: string;
51+
/**
52+
* Pixel ratio scale, will be empty if ratio is 1
53+
* @example "@2x" or "@3x"
54+
*/
55+
scale: string;
56+
}
57+
export async function buildSprites(
58+
ratio: number[],
59+
retina: boolean,
60+
paths: string[],
61+
output?: string,
62+
): Promise<SpriteStats[]> {
4363
if (ratio.length === 0) ratio.push(1, 2);
4464

4565
let baseRatio = 1;
@@ -48,6 +68,8 @@ export async function buildSprites(ratio: number[], retina: boolean, paths: stri
4868
for (let i = 0; i < ratio.length; i++) ratio[i] = ratio[i] * 2;
4969
}
5070

71+
const stats: SpriteStats[] = [];
72+
5173
for (const spritePath of paths) {
5274
const sheetName = path.basename(spritePath);
5375
const sprites = await listSprites(spritePath);
@@ -63,6 +85,15 @@ export async function buildSprites(ratio: number[], retina: boolean, paths: stri
6385

6486
await writeFile(`${outputPath}.json`, JSON.stringify(res.layout, null, 2));
6587
await writeFile(`${outputPath}.png`, res.buffer);
88+
stats.push({
89+
sheet: sheetName,
90+
path: outputPath,
91+
sprites: sprites.length,
92+
ratio: res.pixelRatio,
93+
scale: scaleText,
94+
});
6695
}
6796
}
97+
98+
return stats;
6899
}

packages/sprites/src/fs.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,20 @@ import { readdir, readFile } from 'node:fs/promises';
22
import path, { join, parse } from 'node:path';
33
import { SvgId } from './sprites.js';
44

5-
const ValidExtensions = new Set(['.svg']);
5+
export const ValidExtensions = new Set(['.svg']);
66

77
export async function listSprites(spritePath: string, validExtensions = ValidExtensions): Promise<SvgId[]> {
88
const files = await readdir(spritePath);
99
const sprites = files.filter((f) => validExtensions.has(path.extname(f.toLowerCase())));
10-
if (sprites.length === 0) throw new Error('No .svg files found: ' + spritePath);
10+
if (sprites.length === 0) {
11+
throw new Error('No files found: ' + spritePath + ' with extension: ' + [...ValidExtensions].join(','));
12+
}
1113

1214
return await Promise.all(
1315
sprites.map(async (c) => {
1416
return {
15-
id: parse(c).name, // remove the .svg
16-
svg: await readFile(join(spritePath, c)),
17+
id: parse(c).name, // remove the extension .svg
18+
buffer: await readFile(join(spritePath, c)),
1719
};
1820
}),
1921
);

packages/sprites/src/sprites.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import Sharp, { PngOptions } from 'sharp';
44
export interface SvgId {
55
/** Unique id for the sprite */
66
id: string;
7-
/** Sprite SVG as a buffer */
8-
svg: Buffer;
7+
/** Sprite as buffer */
8+
buffer: Buffer;
99
}
10+
/** Mirror the type SvgID as SpriteId as now sprites can be png, webp etc.. */
11+
export type SpriteId = SvgId;
1012

1113
export interface SpriteSheetLayout {
1214
[id: string]: { width: number; height: number; x: number; y: number; pixelRatio: number };
@@ -41,10 +43,10 @@ export const Sprites = {
4143
const imageData: SpriteLoaded[] = [];
4244
const imageById = new Map<string, SpriteLoaded>();
4345
for (const img of source) {
44-
const metadata = await Sharp(img.svg).metadata();
46+
const metadata = await Sharp(img.buffer).metadata();
4547
if (metadata.width == null || metadata.height == null) throw new Error('Unable to get width of image: ' + img.id);
4648
if (imageById.has(img.id)) throw new Error('Duplicate sprite id ' + img.id);
47-
const data = { width: metadata.width, height: metadata.height, id: img.id, svg: img.svg };
49+
const data = { width: metadata.width, height: metadata.height, id: img.id, buffer: img.buffer };
4850
imageById.set(img.id, data);
4951
imageData.push(data);
5052
}
@@ -69,7 +71,7 @@ export const Sprites = {
6971
const spriteData = imageById.get(String(sprite.id));
7072
if (spriteData == null) throw new Error('Cannot find sprite: ' + sprite.id);
7173
composite.push({
72-
input: await Sharp(spriteData.svg)
74+
input: await Sharp(spriteData.buffer)
7375
.resize({ width: sprite.w * px })
7476
.toBuffer(),
7577
top: sprite.y * px,
269 Bytes
Loading

0 commit comments

Comments
 (0)