Add icon generator with ZIP export and optional PWA manifest

This commit is contained in:
sho 2026-02-26 23:37:38 +09:00
parent d505845f91
commit 9efd7d795e
9 changed files with 764 additions and 176 deletions

View file

@ -93,6 +93,32 @@ tools:
button:
copy: Copy
refresh: Refresh
icon-generator:
title: Icon generator
description: Generate resized app icons from one source image for PWA, Android, and iOS presets.
presets: Presets
presetLabels:
pwa: PWA
android: Android
ios: iOS
fitMode: Fit mode
cover: Cover (crop)
contain: Contain
clearPresetSizes: Clear preset sizes
addSize: Add size
baseName: Base name
baseNameHint: File names use "<baseName>-<size>x<size>.png". Invalid characters are replaced with "-".
includeManifest: Include manifest.json in ZIP
selectedSizes: Selected output sizes
uploadTitle: Drag and drop an image here, or click to select an image
noImage: Upload an image to start.
generate: Generate icons
output: Generated icons
download: Download
downloadAll: Download all
downloadZip: Download zip
percentage-calculator:
title: Percentage calculator
description: Easily calculate percentages from a value to another value, or from a percentage to a value.

View file

@ -72,6 +72,7 @@
"ibantools": "^4.3.3",
"js-base64": "^3.7.6",
"json5": "^2.2.3",
"jszip": "^3.10.1",
"jwt-decode": "^3.1.2",
"libphonenumber-js": "^1.10.28",
"lodash": "^4.17.21",

292
pnpm-lock.yaml generated
View file

@ -49,7 +49,7 @@ importers:
version: 10.3.0(vue@3.3.4)
'@vueuse/head':
specifier: ^1.0.0
version: 1.0.0(typescript@5.2.2)(vue@3.3.4)
version: 1.0.0(vue@3.3.4)
'@vueuse/router':
specifier: ^10.0.0
version: 10.0.0(vue-router@4.1.6(vue@3.3.4))(vue@3.3.4)
@ -113,6 +113,9 @@ importers:
json5:
specifier: ^2.2.3
version: 2.2.3
jszip:
specifier: ^3.10.1
version: 3.10.1
jwt-decode:
specifier: ^3.1.2
version: 3.1.2
@ -2303,9 +2306,6 @@ packages:
'@vue/compiler-core@3.3.7':
resolution: {integrity: sha512-pACdY6YnTNVLXsB86YD8OF9ihwpolzhhtdLVHhBL6do/ykr6kKXNYABRtNMGrsQXpEXXyAdwvWWkuTbs4MFtPQ==}
'@vue/compiler-core@3.5.13':
resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==}
'@vue/compiler-dom@3.2.47':
resolution: {integrity: sha512-dBBnEHEPoftUiS03a4ggEig74J2YBZ2UIeyfpcRM2tavgMWo4bsEfgCGsu+uJIL/vax9S+JztH8NmQerUo7shQ==}
@ -2315,18 +2315,12 @@ packages:
'@vue/compiler-dom@3.3.7':
resolution: {integrity: sha512-0LwkyJjnUPssXv/d1vNJ0PKfBlDoQs7n81CbO6Q0zdL7H1EzqYRrTVXDqdBVqro0aJjo/FOa1qBAPVI4PGSHBw==}
'@vue/compiler-dom@3.5.13':
resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==}
'@vue/compiler-sfc@3.2.47':
resolution: {integrity: sha512-rog05W+2IFfxjMcFw10tM9+f7i/+FFpZJJ5XHX72NP9eC2uRD+42M3pYcQqDXVYoj74kHMSEdQ/WmCjt8JFksQ==}
'@vue/compiler-sfc@3.3.4':
resolution: {integrity: sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==}
'@vue/compiler-sfc@3.5.13':
resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==}
'@vue/compiler-ssr@3.2.47':
resolution: {integrity: sha512-wVXC+gszhulcMD8wpxMsqSOpvDZ6xKXSVWkf50Guf/S+28hTAXPDYRTbLQ3EDkOP5Xz/+SY37YiwDquKbJOgZw==}
@ -2336,9 +2330,6 @@ packages:
'@vue/compiler-ssr@3.3.7':
resolution: {integrity: sha512-TxOfNVVeH3zgBc82kcUv+emNHo+vKnlRrkv8YvQU5+Y5LJGJwSNzcmLUoxD/dNzv0bhQ/F0s+InlgV0NrApJZg==}
'@vue/compiler-ssr@3.5.13':
resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==}
'@vue/devtools-api@6.5.0':
resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==}
@ -2359,21 +2350,12 @@ packages:
'@vue/reactivity@3.3.4':
resolution: {integrity: sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==}
'@vue/reactivity@3.5.13':
resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==}
'@vue/runtime-core@3.3.4':
resolution: {integrity: sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==}
'@vue/runtime-core@3.5.13':
resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==}
'@vue/runtime-dom@3.3.4':
resolution: {integrity: sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==}
'@vue/runtime-dom@3.5.13':
resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==}
'@vue/server-renderer@3.3.4':
resolution: {integrity: sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==}
peerDependencies:
@ -2384,11 +2366,6 @@ packages:
peerDependencies:
vue: 3.3.7
'@vue/server-renderer@3.5.13':
resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==}
peerDependencies:
vue: 3.5.13
'@vue/shared@3.2.47':
resolution: {integrity: sha512-BHGyyGN3Q97EZx0taMQ+OLNuZcW3d37ZEVmEAyeoA9ERdGvm9Irc/0Fua8SNyOtV1w6BS4q25wbMzJujO9HIfQ==}
@ -2398,9 +2375,6 @@ packages:
'@vue/shared@3.3.7':
resolution: {integrity: sha512-N/tbkINRUDExgcPTBvxNkvHGu504k8lzlNQRITVnm6YjOjwa4r0nnbd4Jb01sNpur5hAllyRJzSK5PvB9PPwRg==}
'@vue/shared@3.5.13':
resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==}
'@vue/test-utils@2.3.2':
resolution: {integrity: sha512-hJnVaYhbrIm0yBS0+e1Y0Sj85cMyAi+PAbK4JHqMRUZ6S622Goa+G7QzkRSyvCteG8wop7tipuEbHoZo26wsSA==}
peerDependencies:
@ -2434,8 +2408,10 @@ packages:
'@vueuse/shared@10.3.0':
resolution: {integrity: sha512-kGqCTEuFPMK4+fNWy6dUOiYmxGcUbtznMwBZLC1PubidF4VZY05B+Oht7Jh7/6x4VOWGpvu3R37WHi81cKpiqg==}
'@vueuse/shared@12.0.0':
resolution: {integrity: sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==}
'@vueuse/shared@14.2.1':
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
peerDependencies:
vue: ^3.5.0
'@zhead/schema@1.0.0-beta.13':
resolution: {integrity: sha512-P1A1vRGFBhITco8Iw4/hvnDYoE/SoVrd71dW1pBFdXJb3vP+pBtoOuhbEKy0ROJGOyzQuqvFibcwzyLlWMqNiQ==}
@ -2798,7 +2774,7 @@ packages:
hasBin: true
concat-map@0.0.1:
resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@ -2831,6 +2807,9 @@ packages:
core-js-compat@3.39.0:
resolution: {integrity: sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
country-code-lookup@0.1.0:
resolution: {integrity: sha512-IOI66HEG+8bXfWPy+sTzuN7161vmDZOHg1wgIPFf3WfD73FeLajnn6C+fnxOIa9RL1WRBDMXQQWW/FOaOYaQ3w==}
@ -2896,9 +2875,6 @@ packages:
csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dash-get@1.0.2:
resolution: {integrity: sha512-4FbVrHDwfOASx7uQVxeiCTo7ggSdYZbqs8lH+WU6ViypPlDbe9y6IP5VVUDQBv9DcnyaiPT5XT0UWHgJ64zLeQ==}
@ -3246,6 +3222,7 @@ packages:
eslint-plugin-i@2.28.0-2:
resolution: {integrity: sha512-z48kG4qmE4TmiLcxbmvxMT5ycwvPkXaWW0XpU1L768uZaTbiDbxsHMEdV24JHlOR1xDsPpKW39BfP/pRdYIwFA==}
engines: {node: '>=12'}
deprecated: Please migrate to the brand new `eslint-plugin-import-x` instead
peerDependencies:
eslint: ^7.2.0 || ^8
@ -3649,10 +3626,6 @@ packages:
resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
engines: {node: '>= 0.4.0'}
hasown@2.0.0:
resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@ -3737,6 +3710,9 @@ packages:
engines: {node: '>=0.10.0'}
hasBin: true
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@ -3817,9 +3793,6 @@ packages:
is-core-module@2.13.0:
resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==}
is-core-module@2.13.1:
resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==}
is-core-module@2.16.0:
resolution: {integrity: sha512-urTSINYfAYgcbLb0yDQ6egFm6h3Mo1DcF9EkyXSRjjzdHbsulg01qhwWuXdOoUBuTkbQ80KDboXa0vFJ+BDH+g==}
engines: {node: '>= 0.4'}
@ -3974,6 +3947,9 @@ packages:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@ -4096,6 +4072,9 @@ packages:
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
engines: {node: '>=0.10.0'}
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
jwt-decode@3.1.2:
resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==}
@ -4122,6 +4101,9 @@ packages:
libphonenumber-js@1.10.28:
resolution: {integrity: sha512-1eAgjLrZA0+2Wgw4hs+4Q/kEBycxQo8ZLYnmOvZ3AlM8ImAVAJgDPlZtISLEzD1vunc2q8s2Pn7XwB7I8U3Kzw==}
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@ -4498,6 +4480,9 @@ packages:
package-manager-detector@0.2.7:
resolution: {integrity: sha512-g4+387DXDKlZzHkP+9FLt8yKj8+/3tOkPv7DVTJGGRm00RkEWgqbFstX1mXJ4M0VDYhUqsTOiISqNOJnhAu3PQ==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
param-case@2.1.1:
resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==}
@ -4665,6 +4650,9 @@ packages:
resolution: {integrity: sha512-1q0oC8eRveTg5nnBEWMXAU2qpv65Gnuf2eCQzSjxpWFkPaPARwqZZDGuNE0zPAZfTCHzIk3A8dIjwlQKKLphyg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
prosemirror-changeset@2.2.1:
resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==}
@ -4788,6 +4776,9 @@ packages:
resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==}
engines: {node: '>=8'}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
@ -4868,10 +4859,6 @@ packages:
resolution: {integrity: sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==}
hasBin: true
resolve@1.22.8:
resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
hasBin: true
resolve@1.22.9:
resolution: {integrity: sha512-QxrmX1DzraFIi9PxdG5VkRfRwIgjwyud+z/iBwfRRrVmHc+P9Q7u2lSSpQ6bjr2gy5lrqIiU9vb6iAeGf2400A==}
hasBin: true
@ -4919,6 +4906,9 @@ packages:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@ -4996,6 +4986,9 @@ packages:
resolution: {integrity: sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw==}
engines: {node: '>=11.0'}
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -5071,6 +5064,7 @@ packages:
source-map@0.8.0-beta.0:
resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==}
engines: {node: '>= 8'}
deprecated: The work that was done in this beta branch won't be included in future versions
sourcemap-codec@1.4.8:
resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
@ -5132,6 +5126,9 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
@ -5701,14 +5698,6 @@ packages:
vue@3.3.4:
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
vue@3.5.13:
resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
vuedraggable@4.1.0:
resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
peerDependencies:
@ -6117,7 +6106,7 @@ snapshots:
'@babel/traverse': 7.23.2
'@babel/types': 7.23.0
convert-source-map: 2.0.0
debug: 4.3.4
debug: 4.4.0
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
@ -7000,7 +6989,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.22.10
'@babel/types': 7.22.10
debug: 4.3.4
debug: 4.4.0
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -7015,7 +7004,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.22.5
'@babel/parser': 7.22.5
'@babel/types': 7.22.5
debug: 4.3.4
debug: 4.4.0
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -7030,7 +7019,7 @@ snapshots:
'@babel/helper-split-export-declaration': 7.22.6
'@babel/parser': 7.23.0
'@babel/types': 7.23.0
debug: 4.3.4
debug: 4.4.0
globals: 11.12.0
transitivePeerDependencies:
- supports-color
@ -7228,7 +7217,7 @@ snapshots:
'@eslint/eslintrc@2.1.2':
dependencies:
ajv: 6.12.6
debug: 4.3.4
debug: 4.4.0
espree: 9.6.1
globals: 13.20.0
ignore: 5.2.4
@ -7244,7 +7233,7 @@ snapshots:
'@humanwhocodes/config-array@0.11.10':
dependencies:
'@humanwhocodes/object-schema': 1.2.1
debug: 4.3.4
debug: 4.4.0
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
@ -7264,7 +7253,7 @@ snapshots:
'@antfu/install-pkg': 0.1.1
'@antfu/utils': 0.7.6
'@iconify/types': 2.0.0
debug: 4.3.4
debug: 4.4.0
kolorist: 1.8.0
local-pkg: 0.4.3
transitivePeerDependencies:
@ -7401,7 +7390,7 @@ snapshots:
'@linaria/logger@4.0.0':
dependencies:
debug: 4.3.4
debug: 4.4.0
picocolors: 1.0.0
transitivePeerDependencies:
- supports-color
@ -7922,7 +7911,7 @@ snapshots:
dependencies:
'@typescript-eslint/typescript-estree': 6.4.1(typescript@5.2.2)
'@typescript-eslint/utils': 6.4.1(eslint@8.47.0)(typescript@5.2.2)
debug: 4.3.4
debug: 4.4.0
eslint: 8.47.0
ts-api-utils: 1.0.1(typescript@5.2.2)
optionalDependencies:
@ -7940,10 +7929,10 @@ snapshots:
dependencies:
'@typescript-eslint/types': 5.60.0
'@typescript-eslint/visitor-keys': 5.60.0
debug: 4.3.4
debug: 4.4.0
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
semver: 7.6.3
tsutils: 3.21.0(typescript@5.2.2)
optionalDependencies:
typescript: 5.2.2
@ -7954,10 +7943,10 @@ snapshots:
dependencies:
'@typescript-eslint/types': 6.4.1
'@typescript-eslint/visitor-keys': 6.4.1
debug: 4.3.4
debug: 4.4.0
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
semver: 7.6.3
ts-api-utils: 1.0.1(typescript@5.2.2)
optionalDependencies:
typescript: 5.2.2
@ -7968,10 +7957,10 @@ snapshots:
dependencies:
'@typescript-eslint/types': 6.9.1
'@typescript-eslint/visitor-keys': 6.9.1
debug: 4.3.4
debug: 4.4.0
globby: 11.1.0
is-glob: 4.0.3
semver: 7.5.4
semver: 7.6.3
ts-api-utils: 1.0.1(typescript@5.2.2)
optionalDependencies:
typescript: 5.2.2
@ -7988,7 +7977,7 @@ snapshots:
'@typescript-eslint/typescript-estree': 5.60.0(typescript@5.2.2)
eslint: 8.47.0
eslint-scope: 5.1.1
semver: 7.5.4
semver: 7.6.3
transitivePeerDependencies:
- supports-color
- typescript
@ -8002,7 +7991,7 @@ snapshots:
'@typescript-eslint/types': 6.4.1
'@typescript-eslint/typescript-estree': 6.4.1(typescript@5.2.2)
eslint: 8.47.0
semver: 7.5.4
semver: 7.6.3
transitivePeerDependencies:
- supports-color
- typescript
@ -8016,7 +8005,7 @@ snapshots:
'@typescript-eslint/types': 6.9.1
'@typescript-eslint/typescript-estree': 6.9.1(typescript@5.2.2)
eslint: 8.47.0
semver: 7.5.4
semver: 7.6.3
transitivePeerDependencies:
- supports-color
- typescript
@ -8049,15 +8038,13 @@ snapshots:
dependencies:
'@unhead/schema': 0.5.1
'@unhead/vue@0.5.1(typescript@5.2.2)(vue@3.3.4)':
'@unhead/vue@0.5.1(vue@3.3.4)':
dependencies:
'@unhead/dom': 0.5.1
'@unhead/schema': 0.5.1
'@vueuse/shared': 12.0.0(typescript@5.2.2)
'@vueuse/shared': 14.2.1(vue@3.3.4)
unhead: 0.5.1
vue: 3.3.4
transitivePeerDependencies:
- typescript
'@unocss/astro@0.65.1(rollup@2.79.2)(vite@4.4.9(@types/node@18.15.11)(less@4.1.3)(terser@5.37.0))(vue@3.3.4)':
dependencies:
@ -8341,14 +8328,6 @@ snapshots:
source-map-js: 1.0.2
optional: true
'@vue/compiler-core@3.5.13':
dependencies:
'@babel/parser': 7.26.3
'@vue/shared': 3.5.13
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.2.47':
dependencies:
'@vue/compiler-core': 3.2.47
@ -8365,11 +8344,6 @@ snapshots:
'@vue/shared': 3.3.7
optional: true
'@vue/compiler-dom@3.5.13':
dependencies:
'@vue/compiler-core': 3.5.13
'@vue/shared': 3.5.13
'@vue/compiler-sfc@3.2.47':
dependencies:
'@babel/parser': 7.21.4
@ -8396,18 +8370,6 @@ snapshots:
postcss: 8.4.28
source-map-js: 1.0.2
'@vue/compiler-sfc@3.5.13':
dependencies:
'@babel/parser': 7.26.3
'@vue/compiler-core': 3.5.13
'@vue/compiler-dom': 3.5.13
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
estree-walker: 2.0.2
magic-string: 0.30.15
postcss: 8.4.49
source-map-js: 1.2.1
'@vue/compiler-ssr@3.2.47':
dependencies:
'@vue/compiler-dom': 3.2.47
@ -8424,11 +8386,6 @@ snapshots:
'@vue/shared': 3.3.7
optional: true
'@vue/compiler-ssr@3.5.13':
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/shared': 3.5.13
'@vue/devtools-api@6.5.0': {}
'@vue/language-core@1.8.1(typescript@5.2.2)':
@ -8464,33 +8421,17 @@ snapshots:
dependencies:
'@vue/shared': 3.3.4
'@vue/reactivity@3.5.13':
dependencies:
'@vue/shared': 3.5.13
'@vue/runtime-core@3.3.4':
dependencies:
'@vue/reactivity': 3.3.4
'@vue/shared': 3.3.4
'@vue/runtime-core@3.5.13':
dependencies:
'@vue/reactivity': 3.5.13
'@vue/shared': 3.5.13
'@vue/runtime-dom@3.3.4':
dependencies:
'@vue/runtime-core': 3.3.4
'@vue/shared': 3.3.4
csstype: 3.1.2
'@vue/runtime-dom@3.5.13':
dependencies:
'@vue/reactivity': 3.5.13
'@vue/runtime-core': 3.5.13
'@vue/shared': 3.5.13
csstype: 3.1.3
'@vue/server-renderer@3.3.4(vue@3.3.4)':
dependencies:
'@vue/compiler-ssr': 3.3.4
@ -8504,12 +8445,6 @@ snapshots:
vue: 3.3.4
optional: true
'@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.2.2))':
dependencies:
'@vue/compiler-ssr': 3.5.13
'@vue/shared': 3.5.13
vue: 3.5.13(typescript@5.2.2)
'@vue/shared@3.2.47': {}
'@vue/shared@3.3.4': {}
@ -8517,8 +8452,6 @@ snapshots:
'@vue/shared@3.3.7':
optional: true
'@vue/shared@3.5.13': {}
'@vue/test-utils@2.3.2(vue@3.3.4)':
dependencies:
js-beautify: 1.14.6
@ -8546,14 +8479,12 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/head@1.0.0(typescript@5.2.2)(vue@3.3.4)':
'@vueuse/head@1.0.0(vue@3.3.4)':
dependencies:
'@unhead/schema': 0.5.1
'@unhead/ssr': 0.5.1
'@unhead/vue': 0.5.1(typescript@5.2.2)(vue@3.3.4)
'@unhead/vue': 0.5.1(vue@3.3.4)
vue: 3.3.4
transitivePeerDependencies:
- typescript
'@vueuse/metadata@10.3.0': {}
@ -8580,11 +8511,9 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/shared@12.0.0(typescript@5.2.2)':
'@vueuse/shared@14.2.1(vue@3.3.4)':
dependencies:
vue: 3.5.13(typescript@5.2.2)
transitivePeerDependencies:
- typescript
vue: 3.3.4
'@zhead/schema@1.0.0-beta.13': {}
@ -8608,7 +8537,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.3.4
debug: 4.4.0
transitivePeerDependencies:
- supports-color
@ -8780,7 +8709,7 @@ snapshots:
builtins@5.0.1:
dependencies:
semver: 7.5.4
semver: 7.6.3
bundle-require@5.0.0(esbuild@0.23.1):
dependencies:
@ -9022,6 +8951,8 @@ snapshots:
dependencies:
browserslist: 4.24.3
core-util-is@1.0.3: {}
country-code-lookup@0.1.0: {}
crelt@1.0.6: {}
@ -9084,8 +9015,6 @@ snapshots:
csstype@3.1.2: {}
csstype@3.1.3: {}
dash-get@1.0.2: {}
data-uri-to-buffer@4.0.1: {}
@ -9971,10 +9900,6 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.0:
dependencies:
function-bind: 1.1.2
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@ -10016,14 +9941,14 @@ snapshots:
dependencies:
'@tootallnate/once': 2.0.0
agent-base: 6.0.2
debug: 4.3.4
debug: 4.4.0
transitivePeerDependencies:
- supports-color
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.3.4
debug: 4.4.0
transitivePeerDependencies:
- supports-color
@ -10071,6 +9996,8 @@ snapshots:
image-size@0.5.5:
optional: true
immediate@3.0.6: {}
import-fresh@3.3.0:
dependencies:
parent-module: 1.0.1
@ -10160,10 +10087,6 @@ snapshots:
dependencies:
has: 1.0.3
is-core-module@2.13.1:
dependencies:
hasown: 2.0.0
is-core-module@2.16.0:
dependencies:
hasown: 2.0.2
@ -10293,6 +10216,8 @@ snapshots:
dependencies:
is-docker: 2.2.1
isarray@1.0.0: {}
isarray@2.0.5: {}
isexe@2.0.0: {}
@ -10412,6 +10337,13 @@ snapshots:
jsonpointer@5.0.1: {}
jszip@3.10.1:
dependencies:
lie: 3.3.0
pako: 1.0.11
readable-stream: 2.3.8
setimmediate: 1.0.5
jwt-decode@3.1.2: {}
kind-of@6.0.3: {}
@ -10443,6 +10375,10 @@ snapshots:
libphonenumber-js@1.10.28: {}
lie@3.3.0:
dependencies:
immediate: 3.0.6
lines-and-columns@1.2.4: {}
linkify-it@4.0.1:
@ -10753,7 +10689,7 @@ snapshots:
normalize-package-data@2.5.0:
dependencies:
hosted-git-info: 2.8.9
resolve: 1.22.8
resolve: 1.22.9
semver: 5.7.2
validate-npm-package-license: 3.0.4
@ -10857,6 +10793,8 @@ snapshots:
package-manager-detector@0.2.7: {}
pako@1.0.11: {}
param-case@2.1.1:
dependencies:
no-case: 2.3.2
@ -11017,6 +10955,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 18.2.0
process-nextick-args@2.0.1: {}
prosemirror-changeset@2.2.1:
dependencies:
prosemirror-transform: 1.7.3
@ -11184,6 +11124,16 @@ snapshots:
parse-json: 5.2.0
type-fest: 0.6.0
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
@ -11261,19 +11211,13 @@ snapshots:
resolve@1.22.2:
dependencies:
is-core-module: 2.13.1
is-core-module: 2.16.0
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.4:
dependencies:
is-core-module: 2.13.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.8:
dependencies:
is-core-module: 2.13.1
is-core-module: 2.16.0
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
@ -11322,6 +11266,8 @@ snapshots:
has-symbols: 1.1.0
isarray: 2.0.5
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-regex-test@1.1.0:
@ -11401,6 +11347,8 @@ snapshots:
is-plain-object: 2.0.4
is-primitive: 3.0.1
setimmediate@1.0.5: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@ -11565,6 +11513,10 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.0.0
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
@ -12018,7 +11970,7 @@ snapshots:
vite-node@0.34.0(@types/node@18.15.11)(less@4.1.3)(terser@5.37.0):
dependencies:
cac: 6.7.14
debug: 4.3.4
debug: 4.4.0
mlly: 1.4.0
pathe: 1.1.1
picocolors: 1.0.0
@ -12125,14 +12077,14 @@ snapshots:
vue-eslint-parser@9.3.1(eslint@8.47.0):
dependencies:
debug: 4.3.4
debug: 4.4.0
eslint: 8.47.0
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.5.0
lodash: 4.17.21
semver: 7.5.4
semver: 7.6.3
transitivePeerDependencies:
- supports-color
@ -12180,16 +12132,6 @@ snapshots:
'@vue/server-renderer': 3.3.4(vue@3.3.4)
'@vue/shared': 3.3.4
vue@3.5.13(typescript@5.2.2):
dependencies:
'@vue/compiler-dom': 3.5.13
'@vue/compiler-sfc': 3.5.13
'@vue/runtime-dom': 3.5.13
'@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.2.2))
'@vue/shared': 3.5.13
optionalDependencies:
typescript: 5.2.2
vuedraggable@4.1.0(vue@3.3.4):
dependencies:
sortablejs: 1.14.0

View file

@ -0,0 +1,56 @@
import { Buffer } from 'node:buffer';
import { expect, test } from '@playwright/test';
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512">
<rect width="512" height="512" fill="#3b82f6"/>
<circle cx="256" cy="256" r="140" fill="#f59e0b"/>
</svg>`;
test.describe('Tool - Icon generator', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/icon-generator');
});
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Icon generator - IT Tools');
});
test('shows generator controls and default state', async ({ page }) => {
await expect(page.getByText('Generate icons')).toBeVisible();
await expect(page.getByText('Download zip')).toBeVisible();
await expect(page.getByText('Selected output sizes: -')).toBeVisible();
await expect(page.getByRole('checkbox', { name: 'Include manifest.json in ZIP' })).toBeDisabled();
});
test('applies preset sizes and toggles manifest option with reset', async ({ page }) => {
await page.getByRole('button', { name: 'PWA' }).click();
await expect(page.getByText('Selected output sizes: 72, 96, 128, 144, 152, 192, 384, 512')).toBeVisible();
const manifestCheckbox = page.getByRole('checkbox', { name: 'Include manifest.json in ZIP' });
await expect(manifestCheckbox).toBeEnabled();
await manifestCheckbox.check();
await expect(manifestCheckbox).toBeChecked();
await page.getByRole('button', { name: 'Clear preset sizes' }).click();
await expect(page.getByText('Selected output sizes: -')).toBeVisible();
await expect(manifestCheckbox).toBeDisabled();
await expect(manifestCheckbox).not.toBeChecked();
});
test('generates icons and downloads zip', async ({ page }) => {
await page.getByRole('button', { name: 'PWA' }).click();
await page.locator('input[type="file"]').setInputFiles({
name: 'source.svg',
mimeType: 'image/svg+xml',
buffer: Buffer.from(sampleSvg),
});
await page.getByRole('button', { name: 'Generate icons' }).click();
await expect(page.getByText('Generated icons (8)')).toBeVisible();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download zip' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('icon-icons.zip');
});
});

View file

@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import {
ICON_PRESETS,
buildIconFilename,
calculateDrawArea,
createWebAppManifest,
getPresetSizes,
normalizeSizes,
sanitizeBaseName,
} from './icon-generator.service';
describe('icon-generator', () => {
describe('normalizeSizes', () => {
it('should remove invalid values and deduplicate sizes', () => {
expect(normalizeSizes([192, 192, 0, -1, 512.8, 72])).toEqual([72, 192, 512]);
});
});
describe('getPresetSizes', () => {
it('should return merged and sorted sizes from multiple presets', () => {
const sizes = getPresetSizes(['pwa', 'android']);
expect(sizes).toEqual([48, 72, 96, 128, 144, 152, 192, 384, 512]);
});
it('should include known iOS size', () => {
const sizes = getPresetSizes(['ios']);
expect(sizes).toContain(180);
expect(sizes).toContain(1024);
});
});
describe('calculateDrawArea', () => {
it('should crop in cover mode for rectangular source', () => {
const drawArea = calculateDrawArea({
sourceWidth: 400,
sourceHeight: 200,
targetSize: 100,
fitMode: 'cover',
});
expect(drawArea.width).toBeGreaterThan(100);
expect(drawArea.height).toBe(100);
expect(drawArea.x).toBeLessThan(0);
});
it('should keep full image in contain mode for rectangular source', () => {
const drawArea = calculateDrawArea({
sourceWidth: 400,
sourceHeight: 200,
targetSize: 100,
fitMode: 'contain',
});
expect(drawArea.width).toBe(100);
expect(drawArea.height).toBeLessThan(100);
expect(drawArea.y).toBeGreaterThan(0);
});
});
it('should expose 3 platform presets', () => {
expect(ICON_PRESETS.map(preset => preset.key)).toEqual(['pwa', 'android', 'ios']);
});
it('should sanitize base name and use it in icon filename', () => {
expect(sanitizeBaseName(' My App/Icon ')).toBe('My-App-Icon');
expect(buildIconFilename(192, ' My App/Icon ')).toBe('My-App-Icon-192x192.png');
expect(buildIconFilename(192, ' ')).toBe('icon-192x192.png');
});
it('should create manifest json with provided icons', () => {
const manifestJson = createWebAppManifest({
appName: 'my-app',
description: 'my generated icons',
icons: [
{ filename: 'my-app-192x192.png', size: 192 },
{ filename: 'my-app-512x512.png', size: 512 },
],
});
const manifest = JSON.parse(manifestJson) as {
id: string
name: string
short_name: string
description: string
lang: string
start_url: string
scope: string
icons: { src: string; sizes: string; type: string; purpose: string }[]
};
expect(manifest.id).toBe('/');
expect(manifest.name).toBe('my-app');
expect(manifest.short_name).toBe('my-app');
expect(manifest.description).toBe('my generated icons');
expect(manifest.lang).toBe('en');
expect(manifest.start_url).toBe('/');
expect(manifest.scope).toBe('/');
expect(manifest.icons).toEqual([
{ src: 'my-app-192x192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
{ src: 'my-app-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
]);
});
});

View file

@ -0,0 +1,179 @@
export type IconPresetKey = 'pwa' | 'android' | 'ios';
export type IconFitMode = 'cover' | 'contain';
export interface IconPreset {
key: IconPresetKey
sizes: number[]
}
export interface GeneratedIcon {
size: number
filename: string
dataUrl: string
}
export function createWebAppManifest({
appName,
icons,
description,
startUrl = '/',
scope = '/',
lang = 'en',
backgroundColor = '#ffffff',
themeColor = '#ffffff',
}: {
appName: string
icons: { filename: string; size: number }[]
description?: string
startUrl?: string
scope?: string
lang?: string
backgroundColor?: string
themeColor?: string
}) {
return JSON.stringify({
id: startUrl,
name: appName,
short_name: appName,
description: description ?? `${appName} icons`,
lang,
start_url: startUrl,
scope,
display: 'standalone',
background_color: backgroundColor,
theme_color: themeColor,
icons: icons.map(icon => ({
src: icon.filename,
sizes: `${icon.size}x${icon.size}`,
type: 'image/png',
purpose: 'any maskable',
})),
}, null, 2);
}
export const ICON_PRESETS: IconPreset[] = [
{ key: 'pwa', sizes: [72, 96, 128, 144, 152, 192, 384, 512] },
{ key: 'android', sizes: [48, 72, 96, 144, 192, 512] },
{ key: 'ios', sizes: [20, 29, 40, 58, 60, 76, 80, 87, 120, 152, 167, 180, 1024] },
];
export function normalizeSizes(sizes: number[]) {
return Array.from(
new Set(
sizes
.map(size => Math.floor(size))
.filter(size => Number.isFinite(size) && size > 0),
),
).sort((a, b) => a - b);
}
export function getPresetSizes(presetKeys: IconPresetKey[]) {
const selectedSizes = ICON_PRESETS
.filter(preset => presetKeys.includes(preset.key))
.flatMap(preset => preset.sizes);
return normalizeSizes(selectedSizes);
}
export function sanitizeBaseName(baseName: string) {
const normalized = baseName.trim().replace(/[<>:"/\\|?*]+/g, '-').replace(/\s+/g, '-');
return normalized || 'icon';
}
export function buildIconFilename(size: number, baseName = 'icon') {
const safeBaseName = sanitizeBaseName(baseName);
return `${safeBaseName}-${size}x${size}.png`;
}
export function calculateDrawArea({
sourceWidth,
sourceHeight,
targetSize,
fitMode,
}: {
sourceWidth: number
sourceHeight: number
targetSize: number
fitMode: IconFitMode
}) {
const scale = fitMode === 'cover'
? Math.max(targetSize / sourceWidth, targetSize / sourceHeight)
: Math.min(targetSize / sourceWidth, targetSize / sourceHeight);
const drawWidth = sourceWidth * scale;
const drawHeight = sourceHeight * scale;
return {
x: (targetSize - drawWidth) / 2,
y: (targetSize - drawHeight) / 2,
width: drawWidth,
height: drawHeight,
};
}
export function loadImageFromFile(file: File) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Unable to load image file.'));
};
image.src = url;
});
}
export function createResizedIcon({
image,
size,
fitMode,
}: {
image: HTMLImageElement
size: number
fitMode: IconFitMode
}) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Unable to get canvas context.');
}
const drawArea = calculateDrawArea({
sourceWidth: image.width,
sourceHeight: image.height,
targetSize: size,
fitMode,
});
context.clearRect(0, 0, size, size);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
context.drawImage(image, drawArea.x, drawArea.y, drawArea.width, drawArea.height);
return canvas.toDataURL('image/png');
}
export function createResizedIcons({
image,
sizes,
fitMode,
baseName,
}: {
image: HTMLImageElement
sizes: number[]
fitMode: IconFitMode
baseName?: string
}) {
return normalizeSizes(sizes).map((size): GeneratedIcon => ({
size,
filename: buildIconFilename(size, baseName),
dataUrl: createResizedIcon({ image, size, fitMode }),
}));
}

View file

@ -0,0 +1,267 @@
<script setup lang="ts">
import JSZip from 'jszip';
import {
ICON_PRESETS,
createResizedIcons,
createWebAppManifest,
getPresetSizes,
loadImageFromFile,
normalizeSizes,
sanitizeBaseName,
} from './icon-generator.service';
import type { GeneratedIcon, IconFitMode, IconPresetKey } from './icon-generator.service';
const { t } = useI18n();
const selectedSizesModel = ref<number[]>([]);
const customSizeInput = ref<number | null>(null);
const fitMode = ref<IconFitMode>('cover');
const baseName = ref('icon');
const includeManifest = ref(false);
const sourceFile = ref<File | null>(null);
const sourcePreviewUrl = ref('');
const generatedIcons = ref<GeneratedIcon[]>([]);
const isGenerating = ref(false);
const availableSizes = computed(() =>
normalizeSizes([...ICON_PRESETS.flatMap(preset => preset.sizes), ...selectedSizesModel.value]),
);
const selectedSizes = computed(() => normalizeSizes(selectedSizesModel.value));
const isPwaPresetApplied = computed(() => isPresetApplied('pwa'));
const canGenerate = computed(() => sourceFile.value !== null && selectedSizes.value.length > 0 && !isGenerating.value);
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Unable to read image file.'));
reader.readAsDataURL(file);
});
}
async function onUpload(file: File) {
sourceFile.value = file;
sourcePreviewUrl.value = await readFileAsDataUrl(file);
generatedIcons.value = [];
}
function addCustomSize() {
if (customSizeInput.value == null) {
return;
}
const size = Math.floor(customSizeInput.value);
if (size <= 0) {
return;
}
selectedSizesModel.value = normalizeSizes([...selectedSizesModel.value, size]);
customSizeInput.value = null;
}
function applyPreset(presetKey: IconPresetKey) {
const sizes = getPresetSizes([presetKey]);
selectedSizesModel.value = normalizeSizes([...selectedSizesModel.value, ...sizes]);
}
function isPresetApplied(presetKey: IconPresetKey) {
const sizes = getPresetSizes([presetKey]);
return sizes.every(size => selectedSizesModel.value.includes(size));
}
function clearPresetSizes() {
const presetSizes = normalizeSizes(ICON_PRESETS.flatMap(preset => preset.sizes));
selectedSizesModel.value = selectedSizesModel.value.filter(size => !presetSizes.includes(size));
includeManifest.value = false;
}
function getPresetLabel(presetKey: IconPresetKey) {
return t(`tools.icon-generator.presetLabels.${presetKey}`);
}
async function generateIcons() {
if (!sourceFile.value) {
return;
}
isGenerating.value = true;
try {
const image = await loadImageFromFile(sourceFile.value);
generatedIcons.value = createResizedIcons({
image,
sizes: selectedSizes.value,
fitMode: fitMode.value,
baseName: baseName.value,
});
}
finally {
isGenerating.value = false;
}
}
function downloadIcon(icon: GeneratedIcon) {
const link = document.createElement('a');
link.href = icon.dataUrl;
link.download = icon.filename;
link.click();
}
async function downloadIconsAsZip() {
const zip = new JSZip();
generatedIcons.value.forEach((icon) => {
const [, base64Content] = icon.dataUrl.split(',');
if (!base64Content) {
return;
}
zip.file(icon.filename, base64Content, { base64: true });
});
if (includeManifest.value && isPwaPresetApplied.value) {
const appName = sanitizeBaseName(baseName.value);
zip.file('manifest.json', createWebAppManifest({
appName,
icons: generatedIcons.value.map(icon => ({ filename: icon.filename, size: icon.size })),
}));
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipUrl = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = zipUrl;
link.download = `${sanitizeBaseName(baseName.value)}-icons.zip`;
link.click();
URL.revokeObjectURL(zipUrl);
}
watch(isPwaPresetApplied, (pwaEnabled) => {
if (!pwaEnabled) {
includeManifest.value = false;
}
});
</script>
<template>
<div>
<c-card :title="t('tools.icon-generator.presets')">
<n-space vertical :size="12">
<div class="flex flex-wrap gap-2">
<c-button
v-for="preset in ICON_PRESETS"
:key="preset.key"
:type="isPresetApplied(preset.key) ? 'primary' : 'default'"
@click="applyPreset(preset.key)"
>
{{ getPresetLabel(preset.key) }}
</c-button>
<c-button secondary @click="clearPresetSizes">
{{ t('tools.icon-generator.clearPresetSizes') }}
</c-button>
</div>
<c-buttons-select
v-model:value="fitMode"
:label="`${t('tools.icon-generator.fitMode')}:`"
label-position="left"
:options="[
{ label: t('tools.icon-generator.cover'), value: 'cover' },
{ label: t('tools.icon-generator.contain'), value: 'contain' },
]"
/>
<div class="flex items-center gap-2">
<n-input-number v-model:value="customSizeInput" :min="16" :max="2048" placeholder="256" />
<c-button @click="addCustomSize">
{{ t('tools.icon-generator.addSize') }}
</c-button>
</div>
<c-input-text
v-model:value="baseName"
:label="`${t('tools.icon-generator.baseName')}:`"
placeholder="icon"
label-position="left"
/>
<n-text depth="3">
{{ t('tools.icon-generator.baseNameHint') }}
</n-text>
<n-checkbox v-model:checked="includeManifest" :disabled="!isPwaPresetApplied">
{{ t('tools.icon-generator.includeManifest') }}
</n-checkbox>
<n-checkbox-group v-model:value="selectedSizesModel">
<n-space>
<n-checkbox
v-for="size in availableSizes"
:key="size"
:value="size"
:label="`${size}x${size}`"
/>
</n-space>
</n-checkbox-group>
<n-text depth="3">
{{ t('tools.icon-generator.selectedSizes') }}: {{ selectedSizes.join(', ') || '-' }}
</n-text>
</n-space>
</c-card>
<c-card>
<c-file-upload
:title="t('tools.icon-generator.uploadTitle')"
accept="image/png,image/jpeg,image/webp,image/svg+xml,image/x-icon,image/vnd.microsoft.icon"
@file-upload="onUpload"
/>
<div mt-4 flex flex-col items-center gap-3>
<n-image v-if="sourcePreviewUrl" :src="sourcePreviewUrl" width="180" />
<n-text v-else depth="3">
{{ t('tools.icon-generator.noImage') }}
</n-text>
<div flex flex-wrap justify-center gap-2>
<c-button :disabled="!canGenerate" @click="generateIcons">
{{ t('tools.icon-generator.generate') }}
</c-button>
<c-button :disabled="generatedIcons.length === 0" secondary @click="downloadIconsAsZip">
{{ t('tools.icon-generator.downloadZip') }}
</c-button>
</div>
</div>
</c-card>
<c-card v-if="generatedIcons.length > 0" :title="`${t('tools.icon-generator.output')} (${generatedIcons.length})`">
<n-grid cols="1 520:2 760:3" :x-gap="12" :y-gap="12">
<n-gi v-for="icon in generatedIcons" :key="icon.filename">
<div class="generated-item">
<n-image :src="icon.dataUrl" width="96" preview-disabled />
<div class="text-center">
<n-text strong>
{{ icon.size }}x{{ icon.size }}
</n-text>
</div>
<c-button size="small" @click="downloadIcon(icon)">
{{ t('tools.icon-generator.download') }}
</c-button>
</div>
</n-gi>
</n-grid>
</c-card>
</div>
</template>
<style lang="less" scoped>
.generated-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid var(--c-border-color);
border-radius: 8px;
}
</style>

View file

@ -0,0 +1,13 @@
import { ArrowsShuffle } from '@vicons/tabler';
import { defineTool } from '../tool';
import { translate } from '@/plugins/i18n.plugin';
export const tool = defineTool({
name: translate('tools.icon-generator.title'),
path: '/icon-generator',
description: translate('tools.icon-generator.description'),
keywords: ['icon', 'generator', 'pwa', 'android', 'ios', 'app icon', 'resize', 'favicon'],
component: () => import('./icon-generator.vue'),
icon: ArrowsShuffle,
createdAt: new Date('2026-02-26'),
});

View file

@ -1,6 +1,7 @@
import { tool as base64FileConverter } from './base64-file-converter';
import { tool as base64StringConverter } from './base64-string-converter';
import { tool as basicAuthGenerator } from './basic-auth-generator';
import { tool as iconGenerator } from './icon-generator';
import { tool as emailNormalizer } from './email-normalizer';
import { tool as asciiTextDrawer } from './ascii-text-drawer';
@ -141,7 +142,7 @@ export const toolsByCategory: ToolCategory[] = [
},
{
name: 'Images and videos',
components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder],
components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder, iconGenerator],
},
{
name: 'Development',