From b06251d127a9ed7573d56654dd7096e96908ebf9 Mon Sep 17 00:00:00 2001 From: Ryan Maleki Date: Sun, 25 Oct 2026 15:03:45 -0700 Subject: [PATCH 1/2] Add image metadata extractor --- README.md | 2 +- client/src/App.tsx | 2 + client/src/data/tools.ts | 49 ++++ client/src/pages/tools/image-metadata.tsx | 304 ++++++++++++++++++++++ package-lock.json | 7 + package.json | 1 + tests/e2e/tools/image-metadata.spec.ts | 48 ++++ 7 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 client/src/pages/tools/image-metadata.tsx create mode 100644 tests/e2e/tools/image-metadata.spec.ts diff --git a/README.md b/README.md index 0a4b7dc4..695ec7ae 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Not implemented ### Technical Implementations & Feature Specifications -- **Tool Suite**: Comprehensive conversion tools (URL/CSV to JSON, Number Base, Date, Timezone, Unit), advanced text tools (password generator, UUID v1/v4), and hardware tests (Camera, Microphone, Keyboard) with Web Audio API integration for alerts. Added Metronome with multi-tone functionality. All 48 tools converted to simple React state management for enhanced reliability. +- **Tool Suite**: Comprehensive conversion tools (URL/CSV to JSON, Number Base, Date, Timezone, Unit), image metadata extraction, advanced text tools (password generator, UUID v1/v4), and hardware tests (Camera, Microphone, Keyboard) with Web Audio API integration for alerts. Added Metronome with multi-tone functionality. All 49 tools converted to simple React state management for enhanced reliability. - **Demo System**: Automated tour functionality with customizable speed controls (Slow 8s, Normal 5s, Fast 3s, Very Fast 1.5s), persistent state management, skip/stop controls, and speed preference persistence via localStorage. - **Offline Functionality**: All external dependencies for tools like QR and Barcode generators have been replaced with local JavaScript libraries to ensure 100% offline functionality. - **URL Sharing**: Implemented across all major tools, including timing tools, for state persistence and sharing. diff --git a/client/src/App.tsx b/client/src/App.tsx index 776f3fbe..09c2e356 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -36,6 +36,7 @@ const GraphQLFormatter = lazy(() => import("@/pages/tools/graphql-formatter")); const TimeFormatter = lazy(() => import("@/pages/tools/time-formatter")); const Base64Encoder = lazy(() => import("@/pages/tools/base64-encoder")); const CborEncoder = lazy(() => import("@/pages/tools/cbor-encoder")); +const ImageMetadata = lazy(() => import("@/pages/tools/image-metadata")); const UrlEncoder = lazy(() => import("@/pages/tools/url-encoder")); const JwtDecoder = lazy(() => import("@/pages/tools/jwt-decoder")); const TlsDecoder = lazy(() => import("@/pages/tools/tls-decoder")); @@ -134,6 +135,7 @@ function Router() { {/* Encoders */} + diff --git a/client/src/data/tools.ts b/client/src/data/tools.ts index 6e629da1..78320807 100644 --- a/client/src/data/tools.ts +++ b/client/src/data/tools.ts @@ -28,6 +28,7 @@ import { IndentIncrease, Link, Layers, + FileImage, // Text tools FileText, @@ -3030,6 +3031,54 @@ export const toolsData: ToolData = { ], }, }, + { + name: "Image Metadata", + path: "/tools/image-metadata", + icon: FileImage, + shortcut: "Ctrl+Shift+Alt+I", + metadata: { + title: "Image Metadata - Read EXIF, GPS, and Image Details", + description: + "Extract EXIF, GPS, camera, IPTC, XMP, and other embedded metadata from image files locally in your browser.", + keywords: [ + "image metadata", + "EXIF reader", + "GPS photo metadata", + "camera information", + "photo metadata viewer", + "IPTC", + "XMP", + ], + }, + explanations: { + notice: { + type: "privacy", + title: "Private, Local Image Processing", + items: [ + "Images are parsed locally in your browser and are never uploaded.", + "Metadata depends on what the image file contains; edited or shared images may have had details removed.", + ], + }, + sections: [ + { + title: "Metadata This Tool Can Read", + items: [ + "EXIF camera and lens details, capture settings, dates, and orientation", + "GPS coordinates and location-related tags", + "IPTC and XMP descriptions, creator details, and other embedded fields", + "Image properties and supported embedded color-profile information", + ], + }, + { + title: "Supported Image Files", + items: [ + "JPEG, PNG, TIFF, HEIC/HEIF, AVIF, and other formats recognized by the metadata parser", + "Results vary by file format and by the metadata preserved in the image", + ], + }, + ], + }, + }, ], }, "Text Tools": { diff --git a/client/src/pages/tools/image-metadata.tsx b/client/src/pages/tools/image-metadata.tsx new file mode 100644 index 00000000..e2ab7057 --- /dev/null +++ b/client/src/pages/tools/image-metadata.tsx @@ -0,0 +1,304 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { parse } from "exifr"; +import { Copy, FileImage, Upload } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + ActionButtonGroup, + ClearButton, + DataButtonGroup, + ToolButtonGroup, +} from "@/components/ui/tool-button"; +import { SecurityBanner } from "@/components/ui/security-banner"; +import { ShortcutBadge } from "@/components/ui/shortcut-badge"; +import { Switch } from "@/components/ui/switch"; +import { ToolExplanations } from "@/components/tool-explanations"; +import { getToolByPath } from "@/data/tools"; +import { useToast } from "@/hooks/use-toast"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function stringifyMetadata(metadata: Record): string { + return JSON.stringify( + metadata, + (_, value: unknown) => { + if (typeof value === "bigint") return value.toString(); + if (value instanceof Uint8Array) { + return Array.from(value, byte => + byte.toString(16).padStart(2, "0") + ).join(" "); + } + return value; + }, + 2 + ); +} + +export default function ImageMetadata() { + const tool = getToolByPath("/tools/image-metadata"); + const [imageFile, setImageFile] = useState(null); + const [metadata, setMetadata] = useState | null>( + null + ); + const [hasProcessed, setHasProcessed] = useState(false); + const [isExtracting, setIsExtracting] = useState(false); + const [autoProcess, setAutoProcess] = useState(true); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const extractionId = useRef(0); + const { toast } = useToast(); + + const extractMetadata = useCallback(async (file: File) => { + const currentExtractionId = ++extractionId.current; + setIsExtracting(true); + setError(null); + setMetadata(null); + setHasProcessed(false); + + try { + const result: unknown = await parse(file, true); + if (currentExtractionId !== extractionId.current) return; + + setMetadata( + result && typeof result === "object" + ? Object.fromEntries(Object.entries(result)) + : {} + ); + setHasProcessed(true); + } catch (cause) { + if (currentExtractionId !== extractionId.current) return; + setError( + cause instanceof Error + ? `Could not read this image: ${cause.message}` + : "Could not read this image. The format may not be supported or the file may be invalid." + ); + } finally { + if (currentExtractionId === extractionId.current) { + setIsExtracting(false); + } + } + }, []); + + useEffect(() => { + if (autoProcess && imageFile) { + void extractMetadata(imageFile); + } + }, [autoProcess, extractMetadata, imageFile]); + + useEffect( + () => () => { + extractionId.current += 1; + }, + [] + ); + + const handleFile = (file: File | undefined) => { + if (!file) return; + extractionId.current += 1; + setIsExtracting(false); + setError(null); + setMetadata(null); + setHasProcessed(false); + setImageFile(file); + }; + + const handleClear = () => { + extractionId.current += 1; + setImageFile(null); + setMetadata(null); + setHasProcessed(false); + setIsExtracting(false); + setError(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const copyMetadata = async () => { + if (!metadata) return; + try { + await navigator.clipboard.writeText(stringifyMetadata(metadata)); + toast({ + title: "Metadata copied", + description: "Image metadata copied to the clipboard.", + }); + } catch (cause) { + console.error("Failed to copy image metadata:", cause); + toast({ + title: "Copy failed", + description: "Could not copy metadata to the clipboard.", + variant: "destructive", + }); + } + }; + + const metadataText = metadata ? stringifyMetadata(metadata) : ""; + const hasMetadata = metadata !== null && Object.keys(metadata).length > 0; + + const renderMetadataContent = () => { + if (isExtracting) { + return ( +

+ Reading image metadata locally... +

+ ); + } + if (hasMetadata) { + return ( +
+          {metadataText}
+        
+ ); + } + return ( +

+ No embedded metadata was found in this image. +

+ ); + }; + + return ( +
+
+

+ Image Metadata + {tool?.shortcut ? : null} +

+
+

+ Read EXIF, GPS, camera, and other embedded image details +

+ +
+
+ +

+ Your image stays on this device. Metadata can include private + information such as GPS coordinates. +

+ + {error ? ( + + + {error} + + + ) : null} + + + + +
+ + +
+
+ + + +
+ + + + Choose an image + + + + + {imageFile ? ( +
+ + {imageFile.name} + + {formatBytes(imageFile.size)} + {imageFile.type ? {imageFile.type} : null} +
+ ) : null} +
+
+ + {hasProcessed || isExtracting ? ( + + + Extracted Metadata + {hasMetadata ? ( + + ) : null} + + {renderMetadataContent()} + + ) : null} + + +
+ ); +} diff --git a/package-lock.json b/package-lock.json index 065ba4a4..e5c721e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-unused-imports": "^4.1.4", + "exifr": "^7.1.3", "express": "^4.21.2", "js-yaml": "^4.1.0", "jsbarcode": "^3.12.1", @@ -6161,6 +6162,12 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", diff --git a/package.json b/package.json index 92eb909d..631ce2c1 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-unused-imports": "^4.1.4", "express": "^4.21.2", + "exifr": "^7.1.3", "js-yaml": "^4.1.0", "jsbarcode": "^3.12.1", "jsdom": "^26.1.0", diff --git a/tests/e2e/tools/image-metadata.spec.ts b/tests/e2e/tools/image-metadata.spec.ts new file mode 100644 index 00000000..ba850051 --- /dev/null +++ b/tests/e2e/tools/image-metadata.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "@playwright/test"; +import { setupJSErrorCollection, expectNoErrors } from "./utils"; + +test.describe("Image Metadata Tool", () => { + test.beforeEach(async ({ page }) => { + await setupJSErrorCollection(page); + await page.goto("/tools/image-metadata"); + }); + + test("reads metadata from an uploaded image locally", async ({ page }) => { + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/WQ0AAAAASUVORK5CYII=", + "base64" + ); + + await page.getByTestId("image-file-input").setInputFiles({ + name: "one-pixel.png", + mimeType: "image/png", + buffer: png, + }); + + await expect(page.getByText("one-pixel.png")).toBeVisible(); + await expect(page.getByTestId("image-metadata-output")).toBeVisible(); + await expect(page.getByTestId("image-metadata-output")).toContainText( + "ImageWidth" + ); + await expectNoErrors(page); + }); + + test("can disable automatic extraction and process on demand", async ({ + page, + }) => { + await page.getByTestId("auto-process-switch").click(); + await page.getByTestId("image-file-input").setInputFiles({ + name: "one-pixel.png", + mimeType: "image/png", + buffer: Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/WQ0AAAAASUVORK5CYII=", + "base64" + ), + }); + + await expect(page.getByTestId("extract-metadata")).toBeEnabled(); + await page.getByTestId("extract-metadata").click(); + await expect(page.getByTestId("image-metadata-output")).toBeVisible(); + await expectNoErrors(page); + }); +}); From c8c2418cd50b1ce5b1d7e4fac77e6676961da0b0 Mon Sep 17 00:00:00 2001 From: Ryan Maleki Date: Sun, 25 Oct 2026 15:30:35 -0700 Subject: [PATCH 2/2] Add AI generation metadata read --- README.md | 2 +- client/src/components/layout/Sidebar.tsx | 4 +- client/src/data/tools.ts | 8 +- client/src/lib/c2pa-metadata.ts | 72 +++++++++ client/src/lib/image-ai-indicators.ts | 56 +++++++ client/src/lib/png-text-metadata.ts | 143 ++++++++++++++++ client/src/pages/home.tsx | 2 +- client/src/pages/tools/image-metadata.tsx | 189 +++++++++++++++++++++- package-lock.json | 49 ++++++ package.json | 1 + tests/e2e/menu-toggle.spec.ts | 4 + tests/e2e/tools/image-metadata.spec.ts | 45 ++++++ tests/lib/c2pa-metadata.test.ts | 38 +++++ tests/lib/image-ai-indicators.test.ts | 41 +++++ tests/lib/png-text-metadata.test.ts | 51 ++++++ 15 files changed, 695 insertions(+), 10 deletions(-) create mode 100644 client/src/lib/c2pa-metadata.ts create mode 100644 client/src/lib/image-ai-indicators.ts create mode 100644 client/src/lib/png-text-metadata.ts create mode 100644 tests/lib/c2pa-metadata.test.ts create mode 100644 tests/lib/image-ai-indicators.test.ts create mode 100644 tests/lib/png-text-metadata.test.ts diff --git a/README.md b/README.md index 695ec7ae..c5dc26be 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Not implemented ### Technical Implementations & Feature Specifications -- **Tool Suite**: Comprehensive conversion tools (URL/CSV to JSON, Number Base, Date, Timezone, Unit), image metadata extraction, advanced text tools (password generator, UUID v1/v4), and hardware tests (Camera, Microphone, Keyboard) with Web Audio API integration for alerts. Added Metronome with multi-tone functionality. All 49 tools converted to simple React state management for enhanced reliability. +- **Tool Suite**: Comprehensive conversion tools (URL/CSV to JSON, Number Base, Date, Timezone, Unit), image metadata extraction with local C2PA Content Credentials validation, advanced text tools (password generator, UUID v1/v4), and hardware tests (Camera, Microphone, Keyboard) with Web Audio API integration for alerts. SynthID watermark verification is not included. Added Metronome with multi-tone functionality. All 49 tools converted to simple React state management for enhanced reliability. - **Demo System**: Automated tour functionality with customizable speed controls (Slow 8s, Normal 5s, Fast 3s, Very Fast 1.5s), persistent state management, skip/stop controls, and speed preference persistence via localStorage. - **Offline Functionality**: All external dependencies for tools like QR and Barcode generators have been replaced with local JavaScript libraries to ensure 100% offline functionality. - **URL Sharing**: Implemented across all major tools, including timing tools, for state persistence and sharing. diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx index e45cb01d..6c301396 100644 --- a/client/src/components/layout/Sidebar.tsx +++ b/client/src/components/layout/Sidebar.tsx @@ -99,7 +99,7 @@ export function Sidebar({ {/* NAV */}