From 9efd7d795ed69439cca66f29c495bbaf32fa7dc0 Mon Sep 17 00:00:00 2001 From: sho Date: Thu, 26 Feb 2026 23:37:38 +0900 Subject: [PATCH] Add icon generator with ZIP export and optional PWA manifest --- locales/en.yml | 26 ++ package.json | 1 + pnpm-lock.yaml | 292 +++++++----------- .../icon-generator/icon-generator.e2e.spec.ts | 56 ++++ .../icon-generator.service.test.ts | 103 ++++++ .../icon-generator/icon-generator.service.ts | 179 +++++++++++ src/tools/icon-generator/icon-generator.vue | 267 ++++++++++++++++ src/tools/icon-generator/index.ts | 13 + src/tools/index.ts | 3 +- 9 files changed, 764 insertions(+), 176 deletions(-) create mode 100644 src/tools/icon-generator/icon-generator.e2e.spec.ts create mode 100644 src/tools/icon-generator/icon-generator.service.test.ts create mode 100644 src/tools/icon-generator/icon-generator.service.ts create mode 100644 src/tools/icon-generator/icon-generator.vue create mode 100644 src/tools/icon-generator/index.ts diff --git a/locales/en.yml b/locales/en.yml index d03d80d3..b6932a82 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -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 "-x.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. diff --git a/package.json b/package.json index 5738b632..9776019f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d113893..6565a746 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/tools/icon-generator/icon-generator.e2e.spec.ts b/src/tools/icon-generator/icon-generator.e2e.spec.ts new file mode 100644 index 00000000..2ac549c1 --- /dev/null +++ b/src/tools/icon-generator/icon-generator.e2e.spec.ts @@ -0,0 +1,56 @@ +import { Buffer } from 'node:buffer'; +import { expect, test } from '@playwright/test'; + +const sampleSvg = ` + + +`; + +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'); + }); +}); diff --git a/src/tools/icon-generator/icon-generator.service.test.ts b/src/tools/icon-generator/icon-generator.service.test.ts new file mode 100644 index 00000000..46bfa8a0 --- /dev/null +++ b/src/tools/icon-generator/icon-generator.service.test.ts @@ -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' }, + ]); + }); +}); diff --git a/src/tools/icon-generator/icon-generator.service.ts b/src/tools/icon-generator/icon-generator.service.ts new file mode 100644 index 00000000..b7b33bf0 --- /dev/null +++ b/src/tools/icon-generator/icon-generator.service.ts @@ -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((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 }), + })); +} diff --git a/src/tools/icon-generator/icon-generator.vue b/src/tools/icon-generator/icon-generator.vue new file mode 100644 index 00000000..99e1bde7 --- /dev/null +++ b/src/tools/icon-generator/icon-generator.vue @@ -0,0 +1,267 @@ + + + + + diff --git a/src/tools/icon-generator/index.ts b/src/tools/icon-generator/index.ts new file mode 100644 index 00000000..06b7481c --- /dev/null +++ b/src/tools/icon-generator/index.ts @@ -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'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 388cfaf4..8954b626 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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',