feat(new tool): Image EXIF Reader

- Read EXIF/IPTC/XMP and other metadata from image files
- Estimate JPEG Quality factor
This commit is contained in:
sharevb 2024-01-21 15:21:03 +01:00 committed by ShareVB
parent 670f735501
commit d7c207cc52
6 changed files with 208 additions and 4 deletions

View file

@ -57,11 +57,13 @@
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"dompurify": "^3.0.6", "dompurify": "^3.0.6",
"emojilib": "^3.0.10", "emojilib": "^3.0.10",
"exifreader": "^4.20.0",
"figue": "^1.2.0", "figue": "^1.2.0",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"highlight.js": "^11.7.0", "highlight.js": "^11.7.0",
"iarna-toml-esm": "^3.0.5", "iarna-toml-esm": "^3.0.5",
"ibantools": "^4.3.3", "ibantools": "^4.3.3",
"jpeg-quality-estimator": "^1.0.1",
"json5": "^2.2.3", "json5": "^2.2.3",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"libphonenumber-js": "^1.10.28", "libphonenumber-js": "^1.10.28",

30
pnpm-lock.yaml generated
View file

@ -71,6 +71,9 @@ dependencies:
emojilib: emojilib:
specifier: ^3.0.10 specifier: ^3.0.10
version: 3.0.10 version: 3.0.10
exifreader:
specifier: ^4.20.0
version: 4.20.0
figue: figue:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0 version: 1.2.0
@ -86,6 +89,9 @@ dependencies:
ibantools: ibantools:
specifier: ^4.3.3 specifier: ^4.3.3
version: 4.3.3 version: 4.3.3
jpeg-quality-estimator:
specifier: ^1.0.1
version: 1.0.1
json5: json5:
specifier: ^2.2.3 specifier: ^2.2.3
version: 2.2.3 version: 2.2.3
@ -3374,7 +3380,7 @@ packages:
dependencies: dependencies:
'@unhead/dom': 0.5.1 '@unhead/dom': 0.5.1
'@unhead/schema': 0.5.1 '@unhead/schema': 0.5.1
'@vueuse/shared': 10.6.1(vue@3.3.4) '@vueuse/shared': 10.7.2(vue@3.3.4)
unhead: 0.5.1 unhead: 0.5.1
vue: 3.3.4 vue: 3.3.4
transitivePeerDependencies: transitivePeerDependencies:
@ -4016,8 +4022,8 @@ packages:
- vue - vue
dev: false dev: false
/@vueuse/shared@10.6.1(vue@3.3.4): /@vueuse/shared@10.7.2(vue@3.3.4):
resolution: {integrity: sha512-TECVDTIedFlL0NUfHWncf3zF9Gc4VfdxfQc8JFwoVZQmxpONhLxFrlm0eHQeidHj4rdTPL3KXJa0TZCk1wnc5Q==} resolution: {integrity: sha512-qFbXoxS44pi2FkgFjPvF4h7c9oMDutpyBdcJdMYIMg9XyXli2meFMuaKn+UMgsClo//Th6+beeCgqweT/79BVA==}
dependencies: dependencies:
vue-demi: 0.14.6(vue@3.3.4) vue-demi: 0.14.6(vue@3.3.4)
transitivePeerDependencies: transitivePeerDependencies:
@ -4025,6 +4031,13 @@ packages:
- vue - vue
dev: false dev: false
/@xmldom/xmldom@0.8.10:
resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==}
engines: {node: '>=10.0.0'}
requiresBuild: true
dev: false
optional: true
/@zhead/schema@1.0.0-beta.13: /@zhead/schema@1.0.0-beta.13:
resolution: {integrity: sha512-P1A1vRGFBhITco8Iw4/hvnDYoE/SoVrd71dW1pBFdXJb3vP+pBtoOuhbEKy0ROJGOyzQuqvFibcwzyLlWMqNiQ==} resolution: {integrity: sha512-P1A1vRGFBhITco8Iw4/hvnDYoE/SoVrd71dW1pBFdXJb3vP+pBtoOuhbEKy0ROJGOyzQuqvFibcwzyLlWMqNiQ==}
dev: false dev: false
@ -5567,6 +5580,13 @@ packages:
strip-final-newline: 2.0.0 strip-final-newline: 2.0.0
dev: true dev: true
/exifreader@4.20.0:
resolution: {integrity: sha512-C28BhOHe5svd0Jj/5DGSIXD3PnPp46gfvHN4OkRfvHYZHkcJMhxeUxlwsgJ6Yl62zlZRtmfN+9suZFg0fv4hgg==}
requiresBuild: true
optionalDependencies:
'@xmldom/xmldom': 0.8.10
dev: false
/extend-shallow@2.0.1: /extend-shallow@2.0.1:
resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -6490,6 +6510,10 @@ packages:
hasBin: true hasBin: true
dev: true dev: true
/jpeg-quality-estimator@1.0.1:
resolution: {integrity: sha512-Znaq+msIqs8Gmhg9JSdDjxUAZMOwYXWIURrfluimn5u2yJ4QAEDhf0tnTMkv3ikcHJoJysG5ewxfbqUXyw/Djg==}
dev: false
/js-beautify@1.14.6: /js-beautify@1.14.6:
resolution: {integrity: sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==} resolution: {integrity: sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==}
engines: {node: '>=10'} engines: {node: '>=10'}

View file

@ -0,0 +1,155 @@
<script setup lang="ts">
import ExifReader from 'exifreader';
import getJpegQuality from 'jpeg-quality-estimator';
import { formatBytes } from '@/utils/convert';
interface Tag {
id: number
name: string
value: any
description: string
}
interface TagsSection {
[name: string]: Tag
}
const tagsSections = ref<{ name: string; title: string }[]>([
{ name: 'file', title: 'File Tags' },
{ name: 'jfif', title: 'JFIF Tags' },
{ name: 'pngFile', title: 'PNG File Tags' },
{ name: 'pngText', title: 'PNG Text Tags' },
{ name: 'png', title: 'PNG Tags' },
{ name: 'exif', title: 'EXIF Tags' },
{ name: 'iptc', title: 'IPTC Tags' },
{ name: 'xmp', title: 'XMP Tags' },
{ name: 'icc', title: 'ICC Tags' },
{ name: 'riff', title: 'RIFF Tags' },
{ name: 'gif', title: 'GIF Tags' },
{ name: 'Thumbnail', title: 'Thumbnail Tags' },
{ name: 'photoshop', title: 'Photoshop Tags' },
]);
const errorMessage = ref<string>('');
const tags = ref<ExifReader.ExpandedTags>({});
const status = ref<'idle' | 'parsed' | 'error' | 'loading'>('idle');
const file = ref<File | null>(null);
const quality = ref<number>(-1);
const openStreetMapUrl = computed(
() => {
const gpsLatitude = tags.value.gps?.Latitude;
const gpsLongitude = tags.value.gps?.Longitude;
return gpsLatitude && gpsLongitude ? `https://www.openstreetmap.org/?mlat=${gpsLatitude}&mlon=${gpsLongitude}#map=18/${gpsLatitude}/${gpsLongitude}` : undefined;
},
);
async function onImageUploaded(uploadedFile: File) {
file.value = uploadedFile;
const fileBuffer = await uploadedFile.arrayBuffer();
status.value = 'loading';
try {
quality.value = getJpegQuality(new Uint8Array(fileBuffer));
}
catch (e) {
quality.value = -1;
}
try {
tags.value = await ExifReader.load(fileBuffer, { expanded: true });
status.value = 'parsed';
}
catch (e: any) {
errorMessage.value = e.toString();
status.value = 'error';
}
}
function getSection(sectionName: string): TagsSection | null {
const sections = tags.value as { [name: string]: TagsSection };
return sections[sectionName] ? sections[sectionName] : null;
}
const addSpacesToTagNames = (label: string) => label.replace(/([A-Z][a-z])/g, ' $1').trim();
</script>
<template>
<div style="flex: 0 0 100%">
<div mx-auto max-w-600px>
<c-file-upload title="Drag and drop a Image file here, or click to select a file" @file-upload="onImageUploaded" />
<c-card v-if="file" mt-4 flex gap-2>
<div font-bold>
{{ file.name }}
</div>
<div>
{{ formatBytes(file.size) }}
</div>
<div v-if="tags.Thumbnail">
<img :src="`data:image/jpeg;base64,${tags.Thumbnail.base64}`" max-w-200px>
</div>
</c-card>
<div v-if="status === 'error'">
<c-alert mt-4>
Error parsing image file: {{ errorMessage }}
</c-alert>
</div>
<c-card v-if="quality >= 0" title="JPEG Quality" mt-4>
<input-copyable
label="JPEG Quality (%)"
label-position="left"
label-width="150px"
label-align="right"
mb-2
:value="quality"
/>
</c-card>
<c-card v-if="status === 'parsed' && openStreetMapUrl" title="GPS Infos" mt-4>
<div flex gap-2>
<c-label label="Latitude">
{{ tags.gps?.Latitude?.toFixed(4) }}
</c-label>
<c-label label="Longitude">
{{ tags.gps?.Longitude?.toFixed(4) }}
</c-label>
<c-label label="Altitude">
{{ tags.gps?.Altitude?.toFixed(4) }}
</c-label>
</div>
<c-button :href="openStreetMapUrl" target="_blank" mt-4>
Localize on Open Street Map
</c-button>
</c-card>
<c-card v-if="status === 'parsed' && !openStreetMapUrl" mt-4>
No GPS Information
</c-card>
<div v-if="status === 'parsed'">
<div v-for="section in tagsSections" :key="section.name">
<c-card v-if="getSection(section.name)" :title="section.title" mt-4>
<input-copyable
v-for="({ description }, tagName) in getSection(section.name)"
:key="tagName"
:label="addSpacesToTagNames(String(tagName))"
label-position="left"
label-width="150px"
label-align="right"
mb-2
disabled="disabled"
:value="description ?? '<binary>'"
/>
</c-card>
</div>
</div>
<div v-if="status === 'parsed'" style="flex: 0 0 100%" mt-5 flex flex-col gap-4 />
<div font-size-3>
Made with <a href="https://github.com/mattiasw/ExifReader" target="_blank">ExifReader</a>
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,12 @@
import { FileInfo } from '@vicons/tabler';
import { defineTool } from '../tool';
export const tool = defineTool({
name: 'Image EXIF/Metadata/GPS/JPEG Quality reader',
path: '/image-exif-reader',
description: 'Read EXIF, IPTC, XMP, GPS and other metadata, JPEG Quality, and other infos from images files',
keywords: ['image', 'exif', 'reader', 'iptc', 'gps', 'xmp', 'jpeg', 'quality'],
component: () => import('./image-exif-reader.vue'),
icon: FileInfo,
createdAt: new Date('2024-01-09'),
});

View file

@ -0,0 +1,4 @@
declare module 'jpeg-quality-estimator' {
const getJpegQuality: (file: Uint8Array) => number;
export default getJpegQuality;
}

View file

@ -75,6 +75,7 @@ import { tool as urlParser } from './url-parser';
import { tool as uuidGenerator } from './uuid-generator'; import { tool as uuidGenerator } from './uuid-generator';
import { tool as macAddressLookup } from './mac-address-lookup'; import { tool as macAddressLookup } from './mac-address-lookup';
import { tool as xmlFormatter } from './xml-formatter'; import { tool as xmlFormatter } from './xml-formatter';
import { tool as imageExifReader } from './image-exif-reader';
export const toolsByCategory: ToolCategory[] = [ export const toolsByCategory: ToolCategory[] = [
{ {
@ -124,7 +125,13 @@ export const toolsByCategory: ToolCategory[] = [
}, },
{ {
name: 'Images and videos', name: 'Images and videos',
components: [qrCodeGenerator, wifiQrCodeGenerator, svgPlaceholderGenerator, cameraRecorder], components: [
qrCodeGenerator,
wifiQrCodeGenerator,
svgPlaceholderGenerator,
cameraRecorder,
imageExifReader,
],
}, },
{ {
name: 'Development', name: 'Development',