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): create sprites using sharp #2235

Merged
merged 6 commits into from
Jun 2, 2022
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
4 changes: 2 additions & 2 deletions packages/lambda-tiler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"p-limit": "^4.0.0",
"path-to-regexp": "^6.1.0",
"pixelmatch": "^5.1.0",
"sharp": "^0.29.3"
"sharp": "^0.30.2"
},
"bundle": {
"entry": "src/index.ts",
Expand All @@ -56,7 +56,7 @@
"@types/aws-lambda": "^8.10.75",
"@types/node": "^17.0.34",
"@types/pixelmatch": "^5.0.0",
"@types/sharp": "^0.29.3",
"@types/sharp": "^0.30.2",
"pretty-json-log": "^1.0.0"
}
}
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"@basemaps/landing": "^6.26.0"
},
"dependencies": {
"sharp": "^0.29.3"
"sharp": "^0.30.2"
},
"devDependencies": {
"@basemaps/config": "^6.27.0",
Expand Down
1 change: 1 addition & 0 deletions packages/sprites/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Change Log
44 changes: 44 additions & 0 deletions packages/sprites/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# @basemaps/sprites

Generate a sprite sheet from a list of sprites


# Using the API

```typescript
import { Sprites, SvgId } from '@basemaps/sprites';
import { fsa } from '@chunkd/fs';
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) });
}

const generated = await Sprites.generate(sprites, [1, 2, 4]);
for (const res of generated) {
const scaleText = res.pixelRatio === 1 ? '' : `@${res.pixelRatio}x`;
const outputPng = `./sprites${scaleText}.png`;
const outputJson = `./sprites${scaleText}.json`;

await fsa.write(outputPng, res.buffer);
await fsa.write(outputJson, JSON.stringify(res.layout, null, 2));
}
```


# From the command line

```
npm install -g @basemaps/sprites

basemaps-sprites --ratio 1 --ratio 2 --retina ./config/sprites/topographic
```

Outputs:

topographic.json
topographic.png

topographic@2x.json
topographic@2x.png
5 changes: 5 additions & 0 deletions packages/sprites/bin/basemaps-sprites.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env node

import { SpriteCli } from '../build/cli.js';

new SpriteCli().execute();
43 changes: 43 additions & 0 deletions packages/sprites/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "@basemaps/sprites",
"version": "6.27.0",
"repository": {
"type": "git",
"url": "https://github.com/linz/basemaps.git",
"directory": "packages/sprites"
},
"author": {
"name": "Land Information New Zealand",
"url": "https://linz.govt.nz",
"organization": true
},
"type": "module",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"license": "MIT",
"main": "./build/index.js",
"types": "./build/index.d.ts",
"scripts": {
"test": "ospec --globs 'build/**/*.test.js'"
},
"publishConfig": {
"access": "public"
},
"bin": {
"basemaps-sprites": "bin/basemaps-sprites.mjs"
},
"files": [
"build/",
"bin/"
],
"dependencies": {
"@rushstack/ts-command-line": "^4.3.13",
"@mapbox/shelf-pack": "^3.2.0",
"sharp": "^0.30.2"
},
"devDependencies": {
"@types/mapbox__shelf-pack": "^3.0.1",
"@types/sharp": "^0.30.2"
}
}
21 changes: 21 additions & 0 deletions packages/sprites/src/__test__/readme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Sprites, SvgId } from '../sprites.js';
import { fsa } from '@chunkd/fs';
import { basename } from 'path';

// Validate the readme example actually compiles
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) });
}

const generated = await Sprites.generate(sprites, [1, 2, 4]);
for (const res of generated) {
const scaleText = res.pixelRatio === 1 ? '' : `@${res.pixelRatio}x`;
const outputPng = `./sprites${scaleText}.png`;
const outputJson = `./sprites${scaleText}.json`;

await fsa.write(outputPng, res.buffer);
await fsa.write(outputJson, JSON.stringify(res.layout, null, 2));
}
}
34 changes: 34 additions & 0 deletions packages/sprites/src/__test__/sprite.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createHash } from 'crypto';
import o from 'ospec';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { listSprites } from '../fs.js';
import { Sprites } from '../sprites.js';

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

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

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 },
airport_aerodrome_pnt_fill: { width: 16, height: 16, x: 64, y: 0, pixelRatio: 1 },
mast_pnt: { width: 16, height: 16, x: 80, y: 0, pixelRatio: 1 },
});
const hashA = createHash('sha256').update(res[0].buffer).digest('base64url');
o(hashA).equals('Ggst1UBrnKmtkFokCIkmGSW0S7i0jXEtInfTWWCBVCY');

o(res[1].layout).deepEquals({
embankment_no_gap_cl_thick_wide: { width: 128, height: 64, x: 0, y: 0, pixelRatio: 2 },
airport_aerodrome_pnt_fill: { width: 32, height: 32, x: 128, y: 0, pixelRatio: 2 },
mast_pnt: { width: 32, height: 32, x: 160, y: 0, pixelRatio: 2 },
});
const hashB = createHash('sha256').update(res[1].buffer).digest('base64url');
o(hashB).equals('kM-6X4tpLicvxm1rnIDZq4vultMG5pDutRczJd2MteE');
});
});
56 changes: 56 additions & 0 deletions packages/sprites/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { CommandLineParser } from '@rushstack/ts-command-line';
import { writeFile } from 'fs/promises';
import { basename } from 'path';
import { listSprites } from './fs.js';
import { Sprites } from './sprites.js';

export class SpriteCli extends CommandLineParser {
ratio = this.defineIntegerListParameter({
argumentName: 'RATIO',
parameterLongName: '--ratio',
description: 'Pixel ratio, default: "--ratio 1 --ratio 2"',
});

retina = this.defineFlagParameter({
parameterLongName: '--retina',
description: 'Double the pixel ratios, 1x becomes 2x',
});
r = this.defineCommandLineRemainder({ description: 'Path to sprites' });

constructor() {
super({
toolFilename: 'basemaps-sprites',
toolDescription: 'Create a sprite sheet from a folder of sprites',
});
}

protected onDefineParameters(): void {
// Noop
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can just define the class variables above like private ratio:CommandIntegerListParameter and define them in onDefineParameters(): function.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, because then they are undefined since they are not defined in the constructor. I think its a strict null check setting setting in typescript

class Foo {
  x: number;
  y: number;
  constructor(){ 
    this.x = 1;
  }
  doSomething() {
   this.x // number
   this.y // number | undefined
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, that's why we always checking defined before using them.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

protected async onExecute(): Promise<void> {
if (this.remainder?.values == null || this.remainder.values.length === 0) {
throw new Error('No sprite paths supplied');
}
const ratio = [...this.ratio.values];
if (ratio.length === 0) ratio.push(1, 2);

let baseRatio = 1;
if (this.retina.value) {
baseRatio = 2;
for (let i = 0; i < ratio.length; i++) ratio[i] = ratio[i] * 2;
}

for (const spritePath of this.remainder.values) {
const sheetName = basename(spritePath);
const sprites = await listSprites(spritePath);
const results = await Sprites.generate(sprites, ratio);

for (const res of results) {
const scaleText = res.pixelRatio / baseRatio === 1 ? '' : `@${res.pixelRatio / baseRatio}x`;
await writeFile(`${sheetName}${scaleText}.json`, JSON.stringify(res.layout, null, 2));
await writeFile(`${sheetName}${scaleText}.png`, res.buffer);
}
}
}
}
20 changes: 20 additions & 0 deletions packages/sprites/src/fs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,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 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);

return await Promise.all(
sprites.map(async (c) => {
return {
id: parse(c).name, // remove the .svg
svg: await readFile(join(spritePath, c)),
};
}),
);
}
1 change: 1 addition & 0 deletions packages/sprites/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Sprites, SvgId, SpriteSheetResult, SpriteSheetLayout } from './sprites.js';
93 changes: 93 additions & 0 deletions packages/sprites/src/sprites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import ShelfPack from '@mapbox/shelf-pack';
import Sharp, { PngOptions } from 'sharp';

export interface SvgId {
/** Unique id for the sprite */
id: string;
/** Sprite SVG as a buffer */
svg: Buffer;
}

export interface SpriteSheetLayout {
[id: string]: { width: number; height: number; x: number; y: number; pixelRatio: number };
}

export interface SpriteSheetResult {
/** Pixel ratio that was used to generate the sprite sheet */
pixelRatio: number;
/** Layout to where the sprites were placed */
layout: SpriteSheetLayout;
/** PNG buffer of the sprite sheet */
buffer: Buffer;
}

export type SpriteLoaded = {
width: number;
height: number;
} & SvgId;

const DefaultCompressionOptions: PngOptions = {};

function heightAscThanNameComparator(a: { height: number; id: string }, b: { height: number; id: string }): number {
return b.height - a.height || (a.id === b.id ? 0 : a.id < b.id ? -1 : 1);
}

export const Sprites = {
async generate(
source: SvgId[],
pixelRatio: readonly number[],
compress: PngOptions = DefaultCompressionOptions,
): Promise<SpriteSheetResult[]> {
const imageData: SpriteLoaded[] = [];
const imageById = new Map<string, SpriteLoaded>();
for (const img of source) {
const metadata = await Sharp(img.svg).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 };
imageById.set(img.id, data);
imageData.push(data);
}

imageData.sort(heightAscThanNameComparator);
const sprite = new ShelfPack(1, 1, { autoResize: true });
const sprites = sprite.pack(imageData);

const sheets = pixelRatio.map(async (px): Promise<SpriteSheetResult> => {
const outputImage = Sharp({
create: {
width: sprite.w * px,
height: sprite.h * px,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
});

const layout: SpriteSheetLayout = {};
const composite: Sharp.OverlayOptions[] = [];
for (const sprite of 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)
.resize({ width: sprite.w * px })
.toBuffer(),
top: sprite.y * px,
left: sprite.x * px,
});
layout[sprite.id] = {
width: sprite.w * px,
height: sprite.h * px,
x: sprite.x * px,
y: sprite.y * px,
pixelRatio: px,
};
}

const buffer = await outputImage.composite(composite).png(compress).toBuffer();
return { buffer, pixelRatio: px, layout };
});

return await Promise.all(sheets);
},
};
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/sprites/static/sprites/mast_pnt.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions packages/sprites/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./build"
},
"include": ["src/**/*"],
"references": [{}]
}
4 changes: 2 additions & 2 deletions packages/tiler-sharp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@
"@basemaps/geo": "^6.26.0",
"@basemaps/tiler": "^6.26.0",
"@linzjs/metrics": "^6.21.1",
"sharp": "^0.29.3"
"sharp": "^0.30.2"
},
"devDependencies": {
"@types/pixelmatch": "^5.2.3",
"@types/pngjs": "^6.0.0",
"@types/sharp": "^0.29.3",
"@types/sharp": "^0.30.2",
"pixelmatch": "^5.1.0",
"pngjs": "^6.0.0"
},
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
{ "path": "./packages/linzjs-docker-command" },
{ "path": "./packages/linzjs-geojson" },
{ "path": "./packages/linzjs-metrics" },
{ "path": "./packages/sprites" },
{ "path": "./packages/geo" },
{ "path": "./packages/tiler" },
{ "path": "./packages/tiler-sharp" },
Expand Down
Loading