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

add generator API for creating icons #8

Merged
merged 13 commits into from
Dec 19, 2023
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,35 @@ npm install svg-app-icon
## 👨‍💻 API

```javascript
const path = require('path');
const { promises: fs } = require('fs');
const icons = require('svg-app-icon');
const { generateIcons } = require('svg-app-icon');

(async () => {
const svg = await fs.readFile('my-icon.svg');

await icons(svg, {
destination: './my-output-directory'
});
for await (const icon of generateIcons(svg)) {
await fs.writeFile(path.resolve('./my-output-directory', icon.name), icon.buffer);
}
})();
```

### `icons(svgs, options)` → `Promise`
### `generateIcons(svgs, options)` → `AsyncGenerator`

The arguments for this method are:
* `svgs` _`String`|`Buffer`|`Array<String|Buffer>`_ - the SVG or SVG layers that you'd like to use as the icon. When multiple images are passed in, they will be layered on top of one another in the provided order
* `[options]` _`Object`_ - the options, everything is optional
* `[destination = 'icons']` _`String`_ - the directory to output all icons to. If this direcotry doesn't exist, it will be created
* `[icns = true]` _`Boolean`_ - whether to generate an ICNS icon for MacOS
* `[ico = true]` _`Boolean`_ - whether to generate an ICO icon for Windows
* `[png = true]` _`Boolean`_ - whether to generate all PNG icon sizes for Linux
* `[svg = true]` _`Boolean`_ - whether to generate output the original SVG to the output destination
* `[pngSizes = [32, 256, 512]]` _`Array<Integer>`_ - the sizes to output for PNG icons, in case you need any additional sizes

This promise resolves with `undefined`.
The [`AsyncGenerator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator) will yield `icon` opject. They contain the following properties:
* `name` _`String`_: the name of the file.
* `ext` _`String`_: the extension that should be used for the file. One of `['png', 'icns', 'ico']`
* `buffer` _`Buffer`_: the bytes of the generated icon file
* `size` _`Number`_: optional, only present for `png` icons, this is the size that was used to render the icon

## 💻 CLI

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "svg-app-icon",
"version": "1.2.0",
"description": "create high-quality desktop app icons for Windows, MacOS, and Linux using an SVG source",
"main": "src/maker.js",
"main": "src/index.js",
"bin": "bin/bin.js",
"scripts": {
"lint": "eslint bin/**/*.js src/**/*.js test/**/*.js",
Expand Down
102 changes: 102 additions & 0 deletions src/generator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const renderSvg = require('svg-render');
const toIco = require('@catdad/to-ico');
const { Icns, IcnsImage } = require('@fiahfy/icns');
const { createCanvas, loadImage } = require('canvas');
const cheerio = require('cheerio');

const { toArray } = require('./helpers.js');

const createPng = async (buffers, size) => {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext('2d');

for (const buffer of buffers) {
const png = await renderSvg({ buffer, width: size, height: size });
const image = await loadImage(png);
ctx.drawImage(image, 0, 0);
}

return canvas.toBuffer('image/png');
};

const createIco = async svg => await toIco(
await Promise.all([16, 24, 32, 48, 64, 128, 256].map(size => createPng(svg, size)))
);

const createIcns = async svg => {
const icns = new Icns();

for (const { osType, size, format } of Icns.supportedIconTypes) {
if (format === 'PNG') {
icns.append(IcnsImage.fromPNG(await createPng(svg, size), osType));
}
}

return icns.data;
};

const createSvg = async svg => {
const size = 100;

const sizeNormalized = svg.map(s => {
const $ = cheerio.load(s.toString(), { xmlMode: true });
const $svg = $('svg');
$svg.attr('width', size);
$svg.attr('height', size);
$svg.attr('version', '1.1');
$svg.attr('xmlns', 'http://www.w3.org/2000/svg');

return $.xml('svg');
});

return [
`<svg viewBox="0 0 ${size} ${size}" version="1.1" xmlns="http://www.w3.org/2000/svg">`,
...sizeNormalized,
'</svg>'
].join('\n');
};

const getInputArray = input => {
return toArray(input).map(i => Buffer.isBuffer(i) ? i : Buffer.from(i));
};

async function* generateIcons(input, { icns = true, ico = true, png = true, svg = true, pngSizes = [32, 256, 512] } = {}) {
const buffers = getInputArray(input);

if (svg) {
yield {
name: 'icon.svg',
ext: 'svg',
buffer: Buffer.from(await createSvg(buffers))
};
}

if (ico) {
yield {
name: 'icon.ico',
ext: 'ico',
buffer: Buffer.from(await createIco(buffers))
};
}

if (icns) {
yield {
name: 'icon.icns',
ext: 'icns',
buffer: Buffer.from(await createIcns(buffers))
};
}

if (png) {
for (let size of pngSizes) {
yield {
name: `${size}x${size}.png`,
ext: 'png',
buffer: Buffer.from(await createPng(buffers, size)),
size
};
}
}
}

module.exports = generateIcons;
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module.exports = require('./maker.js');
module.exports.generateIcons = require('./generator.js');
catdad marked this conversation as resolved.
Show resolved Hide resolved
82 changes: 5 additions & 77 deletions src/maker.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
const path = require('path');
const fs = require('fs').promises;

const renderSvg = require('svg-render');
const toIco = require('@catdad/to-ico');
const { Icns, IcnsImage } = require('@fiahfy/icns');
const { createCanvas, loadImage } = require('canvas');
const cheerio = require('cheerio');

const { toArray } = require('./helpers.js');
const generateIcons = require('./generator.js');

const dest = (...parts) => path.resolve('.', ...parts);

Expand All @@ -16,76 +10,10 @@ const write = async (dest, content) => {
await fs.writeFile(dest, content);
};

const createPng = async (buffers, size) => {
const canvas = createCanvas(size, size);
const ctx = canvas.getContext('2d');

for (const buffer of buffers) {
const png = await renderSvg({ buffer, width: size, height: size });
const image = await loadImage(png);
ctx.drawImage(image, 0, 0);
const generateAndWriteToDisk = async (input, { destination = 'icons', ...options } = {}) => {
for await (const icon of generateIcons(input, options)) {
await write(dest(destination, icon.name), icon.buffer);
}

return canvas.toBuffer('image/png');
};

const createIco = async svg => await toIco(
await Promise.all([16, 24, 32, 48, 64, 128, 256].map(size => createPng(svg, size)))
);

const createIcns = async svg => {
const icns = new Icns();

for (const { osType, size } of Icns.supportedIconTypes) {
icns.append(IcnsImage.fromPNG(await createPng(svg, size), osType));
}

return icns.data;
};

const createSvg = async svg => {
const size = 100;

const sizeNormalized = svg.map(s => {
const $ = cheerio.load(s.toString(), { xmlMode: true });
const $svg = $('svg');
$svg.attr('width', size);
$svg.attr('height', size);
$svg.attr('version', '1.1');
$svg.attr('xmlns', 'http://www.w3.org/2000/svg');

return $.xml('svg');
});

return [
`<svg viewBox="0 0 ${size} ${size}" version="1.1" xmlns="http://www.w3.org/2000/svg">`,
...sizeNormalized,
'</svg>'
].join('\n');
};

const getInputArray = input => {
return toArray(input).map(i => Buffer.isBuffer(i) ? i : Buffer.from(i));
};

module.exports = async (input, { destination = 'icons', icns = true, ico = true, png = true, svg = true, pngSizes = [32, 256, 512] } = {}) => {
const buffers = getInputArray(input);

if (svg) {
await write(dest(destination, 'icon.svg'), await createSvg(buffers));
}

if (ico) {
await write(dest(destination, 'icon.ico'), await createIco(buffers));
}

if (icns) {
await write(dest(destination, 'icon.icns'), await createIcns(buffers));
}

if (png) {
for (let size of pngSizes) {
await write(dest(destination, `${size}x${size}.png`), await createPng(buffers, size));
}
}
};
module.exports = generateAndWriteToDisk;
22 changes: 7 additions & 15 deletions test/bin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const lodash = require('lodash');

const pkg = require('../package.json');
const binPath = path.resolve(__dirname, '..', pkg.bin);
const { validateIcons, svg, layers, png, type } = require('./helpers');
const { validateIconsDirectory, svg, layers, png, type, layerHashes } = require('./helpers');

const read = async stream => {
const content = [];
Expand Down Expand Up @@ -78,15 +78,15 @@ describe('app-icon-maker CLI', () => {

await runSuccess(destination);

await validateIcons(path.resolve(destination, 'icons'));
await validateIconsDirectory(path.resolve(destination, 'icons'));
});

it('optionally outputs to a custom destination', async () => {
destination = tempy.directory();

await runSuccess(destination, ['--destination', 'a/b/c']);

await validateIcons(path.resolve(destination, 'a/b/c'));
await validateIconsDirectory(path.resolve(destination, 'a/b/c'));
});

for (let include of ['icns', 'ico', 'png', 'svg']) {
Expand All @@ -102,7 +102,7 @@ describe('app-icon-maker CLI', () => {
svg: include === 'svg',
};

await validateIcons(path.resolve(destination, 'icons'), { ...expected });
await validateIconsDirectory(path.resolve(destination, 'icons'), { ...expected });
});
}

Expand All @@ -118,7 +118,7 @@ describe('app-icon-maker CLI', () => {
svg: true
};

await validateIcons(path.resolve(destination, 'icons'), { ...expected });
await validateIconsDirectory(path.resolve(destination, 'icons'), { ...expected });
});

it('can optionally generate a single arbitrary png size', async () => {
Expand Down Expand Up @@ -184,16 +184,8 @@ describe('app-icon-maker CLI', () => {

await runSuccess(destination, ['-i', 'png', '-i', 'svg', ...lodash.flatten(layerFiles)]);

const hashes = {
'32x32.png': 'c450e4c48d310cac5e1432dc3d8855b9a08da0c1e456eeacdbe4b809c8eb5b27',
'256x256.png': '7413a0717534701a7518a4e35633cae0edb63002c31ef58f092c555f2fa4bdfb',
// it's weird that these two are different?
'512x512.png': '926163d94eb5dd6309861db76e952d8562c83b815583440508f79b8213ed44b7',
'icon.svg': 'bba03b4311a86f6e6f6b7e8b37d444604bca27d95984bd56894ab98857a43cdf'
};

await validateIcons(path.resolve(destination, 'icons'), {
hashes,
await validateIconsDirectory(path.resolve(destination, 'icons'), {
hashes: layerHashes,
icns: false,
ico: false,
png: true,
Expand Down