From c194d90321f896b9572059d46453d7a4ea18d558 Mon Sep 17 00:00:00 2001 From: Blayne Chard Date: Thu, 16 Mar 2023 12:46:25 +1300 Subject: [PATCH 1/2] feat(sprites): support non svg sprites adds support for `--extension .png` to load png (or any other format that libvips supports) as sprites --- packages/sprites/README.md | 15 +++- packages/sprites/bin/basemaps-sprites.mjs | 5 +- packages/sprites/package.json | 2 +- packages/sprites/src/__test__/readme.test.ts | 2 +- packages/sprites/src/__test__/sprite.test.ts | 43 ++++++++- packages/sprites/src/cli.ts | 95 +++++++++++++------- packages/sprites/src/fs.ts | 10 ++- packages/sprites/src/sprites.ts | 12 +-- 8 files changed, 137 insertions(+), 47 deletions(-) diff --git a/packages/sprites/README.md b/packages/sprites/README.md index e18214bb7..f6a263bb9 100644 --- a/packages/sprites/README.md +++ b/packages/sprites/README.md @@ -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]); @@ -41,4 +42,14 @@ topographic.json topographic.png topographic@2x.json -topographic@2x.png \ No newline at end of file +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 +``` \ No newline at end of file diff --git a/packages/sprites/bin/basemaps-sprites.mjs b/packages/sprites/bin/basemaps-sprites.mjs index 3f54cddef..1cfd812e7 100755 --- a/packages/sprites/bin/basemaps-sprites.mjs +++ b/packages/sprites/bin/basemaps-sprites.mjs @@ -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'); +}); diff --git a/packages/sprites/package.json b/packages/sprites/package.json index 8aafc8964..ac554ac9e 100644 --- a/packages/sprites/package.json +++ b/packages/sprites/package.json @@ -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": { diff --git a/packages/sprites/src/__test__/readme.test.ts b/packages/sprites/src/__test__/readme.test.ts index 4119d572c..51c56d353 100644 --- a/packages/sprites/src/__test__/readme.test.ts +++ b/packages/sprites/src/__test__/readme.test.ts @@ -6,7 +6,7 @@ import { basename } from 'path'; export async function main(): Promise { 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]); diff --git a/packages/sprites/src/__test__/sprite.test.ts b/packages/sprites/src/__test__/sprite.test.ts index 45d2e8b98..5b52b64c4 100644 --- a/packages/sprites/src/__test__/sprite.test.ts +++ b/packages/sprites/src/__test__/sprite.test.ts @@ -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'); @@ -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'); + }); }); diff --git a/packages/sprites/src/cli.ts b/packages/sprites/src/cli.ts index 877809df8..b07957c48 100644 --- a/packages/sprites/src/cli.ts +++ b/packages/sprites/src/cli.ts @@ -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 { - 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 { +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 { if (ratio.length === 0) ratio.push(1, 2); let baseRatio = 1; @@ -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); @@ -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; } diff --git a/packages/sprites/src/fs.ts b/packages/sprites/src/fs.ts index a799d502e..3632ddac9 100644 --- a/packages/sprites/src/fs.ts +++ b/packages/sprites/src/fs.ts @@ -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 { 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)), }; }), ); diff --git a/packages/sprites/src/sprites.ts b/packages/sprites/src/sprites.ts index c21c572ad..b8ef219fa 100644 --- a/packages/sprites/src/sprites.ts +++ b/packages/sprites/src/sprites.ts @@ -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 }; @@ -41,10 +43,10 @@ export const Sprites = { const imageData: SpriteLoaded[] = []; const imageById = new Map(); 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); } @@ -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, From 2a7163304be8b3ec1b9fc7bf96f1e2cdea2d6a5c Mon Sep 17 00:00:00 2001 From: Blayne Chard Date: Thu, 16 Mar 2023 12:47:01 +1300 Subject: [PATCH 2/2] refactor: add missing test sprite --- packages/sprites/static/sprites/circle.png | Bin 0 -> 269 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/sprites/static/sprites/circle.png diff --git a/packages/sprites/static/sprites/circle.png b/packages/sprites/static/sprites/circle.png new file mode 100644 index 0000000000000000000000000000000000000000..f2b69828d0f81e281f155d14768a2f384570e596 GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^f*{Pn1|+R>-G2co&H|6fVg?507a+{IwK^ypD7fF# z#WBR9_wFP|u4V%P*VtXT{0}7`9h=(eUH(z7b9>fDmr~2_Z(O>6J{dMmu`0aH)~2Xp z6Q{Oc<6k|a24e_A7K87rJJa16(*<89^#tGl&Y%6Z+qR`ZRPgxcHH{r5_kQopjjp+7 zz*EfhRyxq~m_;q~8dv3emlaH}nsS_Hl;FKoz$SIX^7|88-Xy*mcZ$_S@2_z@R=DR$ zT2RriKdp1`@9gY2`;67wPo{U9