Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.
Expand Down
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -134,6 +135,7 @@ function Router() {
{/* Encoders */}
<Route path="/tools/base64" component={Base64Encoder} />
<Route path="/tools/cbor-encoder" component={CborEncoder} />
<Route path="/tools/image-metadata" component={ImageMetadata} />
<Route path="/tools/url-encoder" component={UrlEncoder} />
<Route path="/tools/jwt-decoder" component={JwtDecoder} />
<Route path="/tools/tls-decoder" component={TlsDecoder} />
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function Sidebar({
{/* NAV */}
<nav className="flex-1 overflow-y-auto no-scrollbar px-2 py-3 space-y-1">
{/* HOME */}
<Link href="/">
<Link href="/" asChild>
<a
onClick={() => {
if (collapsed) onExpandRequest?.("Home");
Expand Down Expand Up @@ -169,7 +169,7 @@ export function Sidebar({
}

return (
<Link key={tool.path} href={tool.path}>
<Link key={tool.path} href={tool.path} asChild>
<a
onClick={onToolClick}
className={cn(
Expand Down
55 changes: 55 additions & 0 deletions client/src/data/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
IndentIncrease,
Link,
Layers,
FileImage,

// Text tools
FileText,
Expand Down Expand Up @@ -3030,6 +3031,60 @@ 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 image metadata and inspect embedded C2PA Content Credentials locally in your browser.",
keywords: [
"image metadata",
"EXIF reader",
"C2PA validator",
"Content Credentials",
"SynthID",
"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",
"Embedded C2PA Content Credentials and their local validation results",
"AI-origin clues from metadata and C2PA digital source type assertions",
"SynthID watermark detection is not available in this offline tool",
],
},
{
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": {
Expand Down
72 changes: 72 additions & 0 deletions client/src/lib/c2pa-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import c2paWasmUrl from "@contentauth/c2pa-web/resources/c2pa.wasm?url";
import type { C2pa, ManifestStore } from "@contentauth/c2pa-web";

let c2paPromise: Promise<C2pa> | undefined;

async function getC2pa() {
if (!c2paPromise) {
c2paPromise = import("@contentauth/c2pa-web")
.then(({ createC2pa }) => createC2pa({ wasmSrc: c2paWasmUrl }))
.catch(error => {
c2paPromise = undefined;
throw error;
});
}
return c2paPromise;
}

export async function readC2paManifest(
file: File
): Promise<ManifestStore | null> {
const [{ Context, Reader }, c2pa] = await Promise.all([
import("@contentauth/c2pa-web"),
getC2pa(),
]);
const reader = await Reader.fromBlob(
c2pa,
file.type || undefined,
file,
new Context({
verify: {
remoteManifestFetch: false,
ocspFetch: false,
},
})
);

if (!reader) return null;
try {
return await reader.manifestStore();
} finally {
await reader.free();
}
}

export function findC2paDigitalSourceTypes(value: unknown): string[] {
const sourceTypes = new Set<string>();
const visited = new WeakSet<object>();

const visit = (current: unknown): void => {
if (!current || typeof current !== "object" || visited.has(current)) return;
visited.add(current);

if (Array.isArray(current)) {
current.forEach(visit);
return;
}

for (const [key, nestedValue] of Object.entries(current)) {
if (
/^(?:digital_source_type|digitalSourceType)$/i.test(key) &&
typeof nestedValue === "string"
) {
sourceTypes.add(nestedValue);
} else {
visit(nestedValue);
}
}
};

visit(value);
return Array.from(sourceTypes);
}
56 changes: 56 additions & 0 deletions client/src/lib/image-ai-indicators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
interface ImageAiIndicator {
field: string;
evidence: string;
}

const generatorPattern =
/\b(?:stable diffusion|midjourney|dall[\s-]?e|adobe firefly|firefly image|generative fill|generative expand|comfyui|automatic1111|a1111|invokeai|fooocus|novelai|stability ai|dreamstudio|black forest labs|flux(?:\s*[-.:]\s*)?(?:dev|schnell|pro|1(?:\.\d+)?|redux)|ideogram|leonardo\.ai|imagen(?:\s*[\d.]*)|gemini image|canva magic media|playground ai|nightcafe|runway(?:\s*ml)?|generated with ai|ai[- ]generated|trainedalgorithmicmedia|compositewithtrainedalgorithmicmedia)\b/i;

const promptFieldPattern =
/^(?:prompt|positive prompt|negative prompt|generation prompt|generation parameters|parameters)$/i;

const diffusionParametersPattern = /\bsteps\s*:\s*\d+.*\bsampler\s*:/i;

export function findImageAiIndicators(
metadata: Record<string, unknown>
): ImageAiIndicator[] {
const indicators: ImageAiIndicator[] = [];
const visited = new WeakSet<object>();

const visit = (value: unknown, field: string): void => {
if (typeof value === "string") {
const generatorMatch = value.match(generatorPattern);
if (generatorMatch) {
indicators.push({ field, evidence: generatorMatch[0] });
} else if (value.trim()) {
const fieldName = field.split(".").at(-1) ?? "";
const hasPromptField = promptFieldPattern.test(fieldName);
const hasDiffusionParameters = diffusionParametersPattern.test(value);
if (!hasPromptField && !hasDiffusionParameters) return;

indicators.push({
field,
evidence: hasDiffusionParameters
? "diffusion-style generation parameters"
: "generation prompt/parameters field",
});
}
return;
}

if (!value || typeof value !== "object" || visited.has(value)) return;
visited.add(value);

if (Array.isArray(value)) {
value.forEach((item, index) => visit(item, `${field}[${index}]`));
return;
}

Object.entries(value).forEach(([key, nestedValue]) =>
visit(nestedValue, field ? `${field}.${key}` : key)
);
};

Object.entries(metadata).forEach(([key, value]) => visit(value, key));
return indicators;
}
143 changes: 143 additions & 0 deletions client/src/lib/png-text-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
const pngSignature = [137, 80, 78, 71, 13, 10, 26, 10];

function readNullTerminatedText(
bytes: Uint8Array,
start: number,
decoder: TextDecoder
): { text: string; next: number } | null {
const end = bytes.indexOf(0, start);
if (end < 0) return null;
return { text: decoder.decode(bytes.subarray(start, end)), next: end + 1 };
}

async function inflateText(bytes: Uint8Array): Promise<Uint8Array> {
if (typeof DecompressionStream === "undefined") {
throw new Error("This browser cannot decompress embedded PNG text.");
}

const stream = new Blob([bytes])
.stream()
.pipeThrough(new DecompressionStream("deflate"));
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let totalLength = 0;

while (true) {
const { done, value } = await reader.read();
if (done) break;
totalLength += value.length;
if (totalLength > 4 * 1024 * 1024) {
await reader.cancel();
throw new Error("Embedded PNG text is larger than 4 MB.");
}
chunks.push(value);
}

const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}

async function readTextChunk(
type: string,
bytes: Uint8Array
): Promise<{ keyword: string; text: string } | null> {
const keyword = readNullTerminatedText(bytes, 0, new TextDecoder("latin1"));
if (!keyword || !keyword.text) return null;

if (type === "tEXt") {
return {
keyword: keyword.text,
text: new TextDecoder("latin1").decode(bytes.subarray(keyword.next)),
};
}

if (type === "zTXt") {
if (bytes[keyword.next] !== 0) return null;
const text = await inflateText(bytes.subarray(keyword.next + 1));
return {
keyword: keyword.text,
text: new TextDecoder("latin1").decode(text),
};
}

const compressionFlag = bytes[keyword.next];
const compressionMethod = bytes[keyword.next + 1];
if (compressionFlag !== 0 && compressionFlag !== 1) return null;
if (compressionFlag === 1 && compressionMethod !== 0) return null;

const language = readNullTerminatedText(
bytes,
keyword.next + 2,
new TextDecoder("ascii")
);
if (!language) return null;
const translatedKeyword = readNullTerminatedText(
bytes,
language.next,
new TextDecoder("utf-8")
);
if (!translatedKeyword) return null;

const textBytes = bytes.subarray(translatedKeyword.next);
const decodedText =
compressionFlag === 1 ? await inflateText(textBytes) : textBytes;

return {
keyword: keyword.text,
text: new TextDecoder("utf-8").decode(decodedText),
};
}

export async function extractPngTextMetadata(
file: Blob
): Promise<Record<string, string>> {
const signature = new Uint8Array(await file.slice(0, 8).arrayBuffer());
if (!pngSignature.every((byte, index) => signature[index] === byte)) {
return {};
}

const metadata: Record<string, string> = {};
let offset = 8;

while (offset + 12 <= file.size) {
const header = new DataView(
await file.slice(offset, offset + 8).arrayBuffer()
);
const chunkLength = header.getUint32(0);
const typeBytes = new Uint8Array(header.buffer, header.byteOffset + 4, 4);
const type = new TextDecoder("ascii").decode(typeBytes);
const dataStart = offset + 8;
const nextChunk = dataStart + chunkLength + 4;

if (nextChunk > file.size) {
throw new Error("The PNG file contains an incomplete chunk.");
}

if (type === "tEXt" || type === "zTXt" || type === "iTXt") {
const data = new Uint8Array(
await file.slice(dataStart, dataStart + chunkLength).arrayBuffer()
);
const textChunk = await readTextChunk(type, data);
if (textChunk) {
const baseKey = `PNG Text.${textChunk.keyword}`;
let key = baseKey;
let duplicate = 2;
while (Object.hasOwn(metadata, key)) {
key = `${baseKey} (${duplicate})`;
duplicate += 1;
}
metadata[key] = textChunk.text;
}
}

offset = nextChunk;
if (type === "IEND") return metadata;
}

throw new Error("The PNG file is missing a complete end chunk.");
}
2 changes: 1 addition & 1 deletion client/src/pages/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export default function Home() {

<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{data.tools.map(tool => (
<Link key={tool.path} href={tool.path}>
<Link key={tool.path} href={tool.path} asChild>
<a href={tool.path} className="block">
<Card
className="
Expand Down
Loading
Loading