Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sprites): support non svg sprites #2736

Merged
merged 2 commits into from
Mar 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/sprites/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import { basename } from 'path';

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

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

topographic@2x.json
topographic@2x.png
topographic@2x.png

Sprites can also be in other formats such as PNG or WebP

```
# Load only png sprites
basemaps-sprites --extension .png ./config/sprites/topographic

# Load png, webp and svg sprites
basemaps-sprites --extension .png --extension .svg --extension .webp ./config/sprites/topographic
```
5 changes: 4 additions & 1 deletion packages/sprites/bin/basemaps-sprites.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
#!/usr/bin/env node

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

new SpriteCli().execute();
run(SpriteCli, process.argv.slice(2)).catch((err) => {
console.error({ err }, 'Command:Failed');
});
2 changes: 1 addition & 1 deletion packages/sprites/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
],
"dependencies": {
"@mapbox/shelf-pack": "^3.2.0",
"@rushstack/ts-command-line": "^4.3.13",
"cmd-ts": "^0.12.1",
"sharp": "^0.30.7"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/sprites/src/__test__/readme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { basename } from 'path';
export async function main(): Promise<void> {
const sprites: SvgId[] = [];
for await (const spritePath of fsa.list('./config/sprites')) {
sprites.push({ id: basename(spritePath).replace('.svg', ''), svg: await fsa.read(spritePath) });
sprites.push({ id: basename(spritePath).replace('.svg', ''), buffer: await fsa.read(spritePath) });
}

const generated = await Sprites.generate(sprites, [1, 2, 4]);
Expand Down
43 changes: 42 additions & 1 deletion packages/sprites/src/__test__/sprite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import { createHash } from 'crypto';
import o from 'ospec';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { listSprites } from '../fs.js';
import { listSprites, ValidExtensions } from '../fs.js';
import { Sprites } from '../sprites.js';

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

o.beforeEach(() => {
ValidExtensions.clear();
ValidExtensions.add('.svg');
});

o.specTimeout(2_500);
o('should generate sprites from examples', async () => {
const baseSprites = join(__dirname, '../../static/sprites');
Expand All @@ -31,4 +36,40 @@ o.spec('Sprites', () => {
const hashB = createHash('sha256').update(res[1].buffer).digest('base64url');
o(hashB).equals('kM-6X4tpLicvxm1rnIDZq4vultMG5pDutRczJd2MteE');
});

o('should generate sprites from from examples including images', async () => {
const baseSprites = join(__dirname, '../../static/sprites');

ValidExtensions.clear();
ValidExtensions.add('.png');

const files = await listSprites(baseSprites);
const res = await Sprites.generate(files, [1, 2]);

o(res[0].layout).deepEquals({
circle: { width: 17, height: 17, x: 0, y: 0, pixelRatio: 1 },
});
const hashA = createHash('sha256').update(res[0].buffer).digest('base64url');
o(hashA).equals('pYs-QfTjaCURzq7MDk1CtYvIEVtoW9dAGwq5hwZil2g');
});

o('should support both svg and png sprites in one image', async () => {
const baseSprites = join(__dirname, '../../static/sprites');

ValidExtensions.clear();
ValidExtensions.add('.png');
ValidExtensions.add('.svg');

const files = await listSprites(baseSprites);
const res = await Sprites.generate(files, [1, 2]);

o(res[0].layout).deepEquals({
embankment_no_gap_cl_thick_wide: { width: 64, height: 32, x: 0, y: 0, pixelRatio: 1 },
circle: { width: 17, height: 17, x: 64, y: 0, pixelRatio: 1 },
airport_aerodrome_pnt_fill: { width: 16, height: 16, x: 81, y: 0, pixelRatio: 1 },
mast_pnt: { width: 16, height: 16, x: 97, y: 0, pixelRatio: 1 },
});
const hashA = createHash('sha256').update(res[0].buffer).digest('base64url');
o(hashA).equals('kVGL6hInF6b1i-2DZYY-TDHGF9HFloOTT5ARaN0sdqA');
});
});
95 changes: 63 additions & 32 deletions packages/sprites/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,65 @@
import { CommandLineParser } from '@rushstack/ts-command-line';
/* eslint-disable no-console */
import { command, multioption, flag, number, restPositionals, array, string } from 'cmd-ts';
import { writeFile } from 'fs/promises';
import path from 'path';
import { listSprites } from './fs.js';
import { listSprites, ValidExtensions } from './fs.js';
import { Sprites } from './sprites.js';
import { promises as fs } from 'fs';

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

retina = this.defineFlagParameter({
parameterLongName: '--retina',
description: 'Double the pixel ratios, 1x becomes 2x',
});
r = this.defineCommandLineRemainder({ description: 'Path to sprites' });
if (args.extensions.length > 0) ValidExtensions.clear();
for (const ext of args.extensions) {
const extName = ext.toLowerCase();
if (extName.startsWith('.')) ValidExtensions.add(extName);
else ValidExtensions.add(`.${extName}`);
}

constructor() {
super({
toolFilename: 'basemaps-sprites',
toolDescription: 'Create a sprite sheet from a folder of sprites',
});
}
const result = await buildSprites(args.ratio, args.retina, args.paths);

protected onDefineParameters(): void {
// Noop
}

protected async onExecute(): Promise<void> {
if (this.remainder?.values == null || this.remainder.values.length === 0) {
throw new Error('No sprite paths supplied');
for (const r of result) {
console.log('Write', r.sprites, 'sprites to', r.path, { ratio: r.ratio });
}
const ratio = [...this.ratio.values];
const paths = [...this.remainder.values];
await buildSprites(ratio, this.retina.value, paths);
}
}
console.log('Done');
},
});

export async function buildSprites(ratio: number[], retina: boolean, paths: string[], output?: string): Promise<void> {
export interface SpriteStats {
/** Sprite sheet name */
sheet: string;
/** Number of sprites found */
sprites: number;
/** Pixel ratio */
ratio: number;
/** Output location */
path: string;
/**
* Pixel ratio scale, will be empty if ratio is 1
* @example "@2x" or "@3x"
*/
scale: string;
}
export async function buildSprites(
ratio: number[],
retina: boolean,
paths: string[],
output?: string,
): Promise<SpriteStats[]> {
if (ratio.length === 0) ratio.push(1, 2);

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

const stats: SpriteStats[] = [];

for (const spritePath of paths) {
const sheetName = path.basename(spritePath);
const sprites = await listSprites(spritePath);
Expand All @@ -63,6 +85,15 @@ export async function buildSprites(ratio: number[], retina: boolean, paths: stri

await writeFile(`${outputPath}.json`, JSON.stringify(res.layout, null, 2));
await writeFile(`${outputPath}.png`, res.buffer);
stats.push({
sheet: sheetName,
path: outputPath,
sprites: sprites.length,
ratio: res.pixelRatio,
scale: scaleText,
});
}
}

return stats;
}
10 changes: 6 additions & 4 deletions packages/sprites/src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@ import { readdir, readFile } from 'node:fs/promises';
import path, { join, parse } from 'node:path';
import { SvgId } from './sprites.js';

const ValidExtensions = new Set(['.svg']);
export const ValidExtensions = new Set(['.svg']);

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

return await Promise.all(
sprites.map(async (c) => {
return {
id: parse(c).name, // remove the .svg
svg: await readFile(join(spritePath, c)),
id: parse(c).name, // remove the extension .svg
buffer: await readFile(join(spritePath, c)),
};
}),
);
Expand Down
12 changes: 7 additions & 5 deletions packages/sprites/src/sprites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import Sharp, { PngOptions } from 'sharp';
export interface SvgId {
/** Unique id for the sprite */
id: string;
/** Sprite SVG as a buffer */
svg: Buffer;
/** Sprite as buffer */
buffer: Buffer;
}
/** Mirror the type SvgID as SpriteId as now sprites can be png, webp etc.. */
export type SpriteId = SvgId;

export interface SpriteSheetLayout {
[id: string]: { width: number; height: number; x: number; y: number; pixelRatio: number };
Expand Down Expand Up @@ -41,10 +43,10 @@ export const Sprites = {
const imageData: SpriteLoaded[] = [];
const imageById = new Map<string, SpriteLoaded>();
for (const img of source) {
const metadata = await Sharp(img.svg).metadata();
const metadata = await Sharp(img.buffer).metadata();
if (metadata.width == null || metadata.height == null) throw new Error('Unable to get width of image: ' + img.id);
if (imageById.has(img.id)) throw new Error('Duplicate sprite id ' + img.id);
const data = { width: metadata.width, height: metadata.height, id: img.id, svg: img.svg };
const data = { width: metadata.width, height: metadata.height, id: img.id, buffer: img.buffer };
imageById.set(img.id, data);
imageData.push(data);
}
Expand All @@ -69,7 +71,7 @@ export const Sprites = {
const spriteData = imageById.get(String(sprite.id));
if (spriteData == null) throw new Error('Cannot find sprite: ' + sprite.id);
composite.push({
input: await Sharp(spriteData.svg)
input: await Sharp(spriteData.buffer)
.resize({ width: sprite.w * px })
.toBuffer(),
top: sprite.y * px,
Expand Down
Binary file added packages/sprites/static/sprites/circle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.