From d3ee99a2b05cb5b01f015938ee4f5ec68c383a32 Mon Sep 17 00:00:00 2001 From: sharevb Date: Sun, 28 Apr 2024 12:37:41 +0200 Subject: [PATCH] feat(new tool): HEIC Converter Fix #997 --- components.d.ts | 3 + package.json | 2 + pnpm-lock.yaml | 53 ++++++++++-- src/composable/downloadBase64.ts | 91 ++++++++++++++++----- src/tools/heic-converter/heic-convert.d.ts | 30 +++++++ src/tools/heic-converter/heic-converter.vue | 87 ++++++++++++++++++++ src/tools/heic-converter/index.ts | 12 +++ src/tools/index.ts | 9 +- 8 files changed, 261 insertions(+), 26 deletions(-) create mode 100644 src/tools/heic-converter/heic-convert.d.ts create mode 100644 src/tools/heic-converter/heic-converter.vue create mode 100644 src/tools/heic-converter/index.ts diff --git a/components.d.ts b/components.d.ts index e31119b3..00ebe99a 100644 --- a/components.d.ts +++ b/components.d.ts @@ -82,6 +82,7 @@ declare module '@vue/runtime-core' { GitMemo: typeof import('./src/tools/git-memo/git-memo.vue')['default'] 'GitMemo.content': typeof import('./src/tools/git-memo/git-memo.content.md')['default'] HashText: typeof import('./src/tools/hash-text/hash-text.vue')['default'] + HeicConverter: typeof import('./src/tools/heic-converter/heic-converter.vue')['default'] HmacGenerator: typeof import('./src/tools/hmac-generator/hmac-generator.vue')['default'] 'Home.page': typeof import('./src/pages/Home.page.vue')['default'] HtmlEntities: typeof import('./src/tools/html-entities/html-entities.vue')['default'] @@ -129,6 +130,7 @@ declare module '@vue/runtime-core' { NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] NCode: typeof import('naive-ui')['NCode'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] + NColorPicker: typeof import('naive-ui')['NColorPicker'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] NDivider: typeof import('naive-ui')['NDivider'] NEllipsis: typeof import('naive-ui')['NEllipsis'] @@ -159,6 +161,7 @@ declare module '@vue/runtime-core' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] RsaKeyPairGenerator: typeof import('./src/tools/rsa-key-pair-generator/rsa-key-pair-generator.vue')['default'] + SafelinkDecoder: typeof import('./src/tools/safelink-decoder/safelink-decoder.vue')['default'] SlugifyString: typeof import('./src/tools/slugify-string/slugify-string.vue')['default'] SpanCopyable: typeof import('./src/components/SpanCopyable.vue')['default'] SqlPrettify: typeof import('./src/tools/sql-prettify/sql-prettify.vue')['default'] diff --git a/package.json b/package.json index fd6c02e6..37c0541e 100644 --- a/package.json +++ b/package.json @@ -61,9 +61,11 @@ "figlet": "^1.7.0", "figue": "^1.2.0", "fuse.js": "^6.6.2", + "heic-convert": "^2.1.0", "highlight.js": "^11.7.0", "iarna-toml-esm": "^3.0.5", "ibantools": "^4.3.3", + "js-base64": "^3.7.7", "json5": "^2.2.3", "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.28", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd6c38c9..9788e6c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ dependencies: fuse.js: specifier: ^6.6.2 version: 6.6.2 + heic-convert: + specifier: ^2.1.0 + version: 2.1.0 highlight.js: specifier: ^11.7.0 version: 11.7.0 @@ -92,6 +95,9 @@ dependencies: ibantools: specifier: ^4.3.3 version: 4.3.3 + js-base64: + specifier: ^3.7.7 + version: 3.7.7 json5: specifier: ^2.2.3 version: 2.2.3 @@ -3351,7 +3357,7 @@ packages: dependencies: '@unhead/dom': 0.5.1 '@unhead/schema': 0.5.1 - '@vueuse/shared': 10.7.2(vue@3.3.4) + '@vueuse/shared': 10.9.0(vue@3.3.4) unhead: 0.5.1 vue: 3.3.4 transitivePeerDependencies: @@ -3993,10 +3999,10 @@ packages: - vue dev: false - /@vueuse/shared@10.7.2(vue@3.3.4): - resolution: {integrity: sha512-qFbXoxS44pi2FkgFjPvF4h7c9oMDutpyBdcJdMYIMg9XyXli2meFMuaKn+UMgsClo//Th6+beeCgqweT/79BVA==} + /@vueuse/shared@10.9.0(vue@3.3.4): + resolution: {integrity: sha512-Uud2IWncmAfJvRaFYzv5OHDli+FbOzxiVEQdLCKQKLyhz94PIyFC3CHcH7EDMwIn8NPtD06+PNbC/PiO0LGLtw==} dependencies: - vue-demi: 0.14.6(vue@3.3.4) + vue-demi: 0.14.7(vue@3.3.4) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -5994,6 +6000,22 @@ packages: tslib: 2.5.0 dev: false + /heic-convert@2.1.0: + resolution: {integrity: sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==} + engines: {node: '>=12.0.0'} + dependencies: + heic-decode: 2.0.0 + jpeg-js: 0.4.4 + pngjs: 6.0.0 + dev: false + + /heic-decode@2.0.0: + resolution: {integrity: sha512-NU+zsiDvdL+EebyTjrEqjkO2XYI7FgLhQzsbmO8dnnYce3S0PBSDm/ZyI4KpcGPXYEdb5W72vp/AQFuc4F8ASg==} + engines: {node: '>=8.0.0'} + dependencies: + libheif-js: 1.17.1 + dev: false + /highlight.js@11.7.0: resolution: {integrity: sha512-1rRqesRFhMO/PRF+G86evnyJkCgaZFOI+Z6kdj15TA18funfoqJXvgPCLSf0SWq3SRfg1j3HlDs8o4s3EGq1oQ==} engines: {node: '>=12.0.0'} @@ -6472,6 +6494,14 @@ packages: hasBin: true dev: true + /jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + dev: false + + /js-base64@3.7.7: + resolution: {integrity: sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw==} + dev: false + /js-beautify@1.14.6: resolution: {integrity: sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==} engines: {node: '>=10'} @@ -6656,6 +6686,11 @@ packages: type-check: 0.4.0 dev: true + /libheif-js@1.17.1: + resolution: {integrity: sha512-g9wBm/CasGZMjmH3B2sD9+AO7Y5+79F0oPS+sdAulSxQeYeCeiTIP+lDqvlPofD+y76wvfVtotKZ8AuvZQnWgg==} + engines: {node: '>=8.0.0'} + dev: false + /libphonenumber-js@1.10.28: resolution: {integrity: sha512-1eAgjLrZA0+2Wgw4hs+4Q/kEBycxQo8ZLYnmOvZ3AlM8ImAVAJgDPlZtISLEzD1vunc2q8s2Pn7XwB7I8U3Kzw==} dev: false @@ -7429,6 +7464,11 @@ packages: engines: {node: '>=10.13.0'} dev: false + /pngjs@6.0.0: + resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} + engines: {node: '>=12.13.0'} + dev: false + /postcss-selector-parser@6.0.13: resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==} engines: {node: '>=4'} @@ -9151,8 +9191,8 @@ packages: vue: 3.3.4 dev: false - /vue-demi@0.14.6(vue@3.3.4): - resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} + /vue-demi@0.14.7(vue@3.3.4): + resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} engines: {node: '>=12'} hasBin: true requiresBuild: true @@ -9442,6 +9482,7 @@ packages: /workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained dependencies: workbox-background-sync: 7.0.0 workbox-core: 7.0.0 diff --git a/src/composable/downloadBase64.ts b/src/composable/downloadBase64.ts index 37b0428d..3bc20226 100644 --- a/src/composable/downloadBase64.ts +++ b/src/composable/downloadBase64.ts @@ -1,8 +1,13 @@ -import { extension as getExtensionFromMime } from 'mime-types'; +import { extension as getExtensionFromMimeType, extension as getMimeTypeFromExtension } from 'mime-types'; import type { Ref } from 'vue'; import _ from 'lodash'; -export { getMimeTypeFromBase64, useDownloadFileFromBase64 }; +export { + getMimeTypeFromBase64, + getMimeTypeFromExtension, getExtensionFromMimeType, + useDownloadFileFromBase64, useDownloadFileFromBase64Refs, + previewImageFromBase64, +}; const commonMimeTypesSignatures = { 'JVBERi0': 'application/pdf', @@ -36,30 +41,78 @@ function getFileExtensionFromMimeType({ defaultExtension?: string }) { if (mimeType) { - return getExtensionFromMime(mimeType) ?? defaultExtension; + return getExtensionFromMimeType(mimeType) ?? defaultExtension; } return defaultExtension; } -function useDownloadFileFromBase64({ source, filename }: { source: Ref; filename?: string }) { +function downloadFromBase64({ sourceValue, filename, extension, fileMimeType }: +{ sourceValue: string; filename?: string; extension?: string; fileMimeType?: string }) { + if (sourceValue === '') { + throw new Error('Base64 string is empty'); + } + + const defaultExtension = extension ?? 'txt'; + const { mimeType } = getMimeTypeFromBase64({ base64String: sourceValue }); + let base64String = sourceValue; + if (!mimeType) { + const targetMimeType = fileMimeType ?? getMimeTypeFromExtension(defaultExtension); + base64String = `data:${targetMimeType};base64,${sourceValue}`; + } + + const cleanExtension = extension ?? getFileExtensionFromMimeType( + { mimeType, defaultExtension }); + let cleanFileName = filename ?? `file.${cleanExtension}`; + if (extension && !cleanFileName.endsWith(`.${extension}`)) { + cleanFileName = `${cleanFileName}.${cleanExtension}`; + } + + const a = document.createElement('a'); + a.href = base64String; + a.download = cleanFileName; + a.click(); +} + +function useDownloadFileFromBase64( + { source, filename, extension, fileMimeType }: + { source: Ref; filename?: string; extension?: string; fileMimeType?: string }) { return { download() { - if (source.value === '') { - throw new Error('Base64 string is empty'); - } - - const { mimeType } = getMimeTypeFromBase64({ base64String: source.value }); - const base64String = mimeType - ? source.value - : `data:text/plain;base64,${source.value}`; - - const cleanFileName = filename ?? `file.${getFileExtensionFromMimeType({ mimeType })}`; - - const a = document.createElement('a'); - a.href = base64String; - a.download = cleanFileName; - a.click(); + downloadFromBase64({ sourceValue: source.value, filename, extension, fileMimeType }); }, }; } + +function useDownloadFileFromBase64Refs( + { source, filename, extension }: + { source: Ref; filename?: Ref; extension?: Ref }) { + return { + download() { + downloadFromBase64({ sourceValue: source.value, filename: filename?.value, extension: extension?.value }); + }, + }; +} + +function previewImageFromBase64(base64String: string): HTMLImageElement { + if (base64String === '') { + throw new Error('Base64 string is empty'); + } + + const img = document.createElement('img'); + img.src = base64String; + + const container = document.createElement('div'); + container.appendChild(img); + + const previewContainer = document.getElementById('previewContainer'); + if (previewContainer) { + previewContainer.innerHTML = ''; + previewContainer.appendChild(container); + } + else { + throw new Error('Preview container element not found'); + } + + return img; +} diff --git a/src/tools/heic-converter/heic-convert.d.ts b/src/tools/heic-converter/heic-convert.d.ts new file mode 100644 index 00000000..1eb95aa7 --- /dev/null +++ b/src/tools/heic-converter/heic-convert.d.ts @@ -0,0 +1,30 @@ +declare module 'heic-convert/browser' { + interface ConversionOptions { + /** + * the HEIC file buffer + */ + buffer: ArrayBufferLike; + /** + * output format + */ + format: "JPEG" | "PNG"; + /** + * the JPEG compression quality, between 0 and 1 + * @default 0.92 + */ + quality?: number; + } + + interface Convertible { + convert(): Promise; + } + + /** @async */ + declare function convert(image: ConversionOptions): Promise; + declare namespace convert { + /** @async */ + function all(image: ConversionOptions): Promise; + } + + export default convert; +} \ No newline at end of file diff --git a/src/tools/heic-converter/heic-converter.vue b/src/tools/heic-converter/heic-converter.vue new file mode 100644 index 00000000..4a10cee6 --- /dev/null +++ b/src/tools/heic-converter/heic-converter.vue @@ -0,0 +1,87 @@ + + + diff --git a/src/tools/heic-converter/index.ts b/src/tools/heic-converter/index.ts new file mode 100644 index 00000000..f8eb382d --- /dev/null +++ b/src/tools/heic-converter/index.ts @@ -0,0 +1,12 @@ +import { Photo } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'HEIC Converter', + path: '/heic-converter', + description: 'HEIC Converter to JPEG or PNG', + keywords: ['heic', 'heif', 'convert', 'decoder', 'converter'], + component: () => import('./heic-converter.vue'), + icon: Photo, + createdAt: new Date('2024-04-28'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index aa861c93..7562e607 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -6,6 +6,7 @@ import { tool as asciiTextDrawer } from './ascii-text-drawer'; import { tool as textToUnicode } from './text-to-unicode'; import { tool as safelinkDecoder } from './safelink-decoder'; +import { tool as heicConverter } from './heic-converter'; import { tool as pdfSignatureChecker } from './pdf-signature-checker'; import { tool as numeronymGenerator } from './numeronym-generator'; import { tool as macAddressGenerator } from './mac-address-generator'; @@ -132,7 +133,13 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Images and videos', - components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder], + components: [ + qrCodeGenerator, + wifiQrCodeGenerator, + svgPlaceholderGenerator, + cameraRecorder, + heicConverter, + ], }, { name: 'Development',