// src/lib/images/metadata.ts — from tinytinytools.com // Served verbatim from the working tree. Method: https://tinytinytools.com/guides /** * Reading and removing image metadata. * * This module has been extracted and published as `strip-image-metadata` * (https://github.com/sagarlimbucreates/strip-image-metadata), which is the version to * fix bugs in first — it has the wider test suite and the WebP VP8X flag handling this * copy still lacks. Swap this file for the package once it is on npm. * * Two rules shape this module. * * First, stripping is byte-level surgery, not re-encoding. A JPEG's metadata lives in * APP segments that sit alongside the compressed scan data; removing those segments and * copying the rest leaves every pixel byte untouched, so the image is bit-for-bit the * same picture. Re-encoding through a canvas would also drop metadata, but it would * recompress the image and quietly lose quality — unacceptable for a tool whose whole * promise is "this only removes the data you cannot see". * * Second, the tool shows what it found before it removes anything. A list of tag names * means little; "taken at 51.5007, -0.1246" is the fact that makes someone understand * why they are here. */ export type GpsPosition = { readonly latitude: number; readonly longitude: number }; export type ImageMetadata = { readonly format: 'jpeg' | 'png' | 'webp' | 'heic' | 'unknown'; readonly gps?: GpsPosition; readonly make?: string; readonly model?: string; readonly lens?: string; readonly serialNumber?: string; readonly dateTaken?: string; readonly software?: string; /** Container-level blocks found, e.g. 'EXIF', 'XMP', 'IPTC'. */ readonly blocks: readonly string[]; readonly metadataBytes: number; }; const ascii = (bytes: Uint8Array, start: number, length: number) => String.fromCharCode(...bytes.subarray(start, start + length)); export function detectFormat(bytes: Uint8Array): ImageMetadata['format'] { if (bytes.length > 3 && bytes[0] === 0xff && bytes[1] === 0xd8) return 'jpeg'; if (bytes.length > 8 && bytes[0] === 0x89 && ascii(bytes, 1, 3) === 'PNG') return 'png'; if (bytes.length >= 12 && ascii(bytes, 0, 4) === 'RIFF' && ascii(bytes, 8, 4) === 'WEBP') return 'webp'; if (bytes.length >= 12 && ascii(bytes, 4, 4) === 'ftyp' && /hei|mif1|msf1|avif/.test(ascii(bytes, 8, 4))) return 'heic'; return 'unknown'; } /* ------------------------------- TIFF / EXIF ------------------------------ */ const TAGS = { make: 0x010f, model: 0x0110, software: 0x0131, dateTime: 0x0132, exifIfd: 0x8769, gpsIfd: 0x8825, dateTimeOriginal: 0x9003, lensModel: 0xa434, bodySerial: 0xa431, } as const; const GPS = { latRef: 1, lat: 2, lonRef: 3, lon: 4 } as const; type Reader = { u16(offset: number): number; u32(offset: number): number; rational(offset: number): number; text(offset: number, length: number): string; }; function tiffReader(view: DataView, tiffStart: number, little: boolean): Reader { return { u16: offset => view.getUint16(tiffStart + offset, little), u32: offset => view.getUint32(tiffStart + offset, little), rational: offset => { const numerator = view.getUint32(tiffStart + offset, little); const denominator = view.getUint32(tiffStart + offset + 4, little); return denominator === 0 ? 0 : numerator / denominator; }, text: (offset, length) => { let out = ''; for (let index = 0; index < length; index += 1) { const code = view.getUint8(tiffStart + offset + index); if (code === 0) break; out += String.fromCharCode(code); } return out.trim(); }, }; } /** Degrees/minutes/seconds triple -> signed decimal degrees. */ function dmsToDecimal(read: Reader, valueOffset: number, ref: string): number { const degrees = read.rational(valueOffset); const minutes = read.rational(valueOffset + 8); const seconds = read.rational(valueOffset + 16); const decimal = degrees + minutes / 60 + seconds / 3600; return ref === 'S' || ref === 'W' ? -decimal : decimal; } /** Parse a TIFF block (the payload of an EXIF APP1 segment or PNG eXIf chunk). */ export function parseTiff(tiff: Uint8Array): Partial { if (tiff.length < 8) return {}; const view = new DataView(tiff.buffer, tiff.byteOffset, tiff.byteLength); const order = String.fromCharCode(tiff[0]!, tiff[1]!); if (order !== 'II' && order !== 'MM') return {}; const little = order === 'II'; const read = tiffReader(view, 0, little); if (read.u16(2) !== 0x2a) return {}; const out: Record = {}; let gpsOffset = 0; let exifOffset = 0; const walk = (ifdOffset: number, into: 'root' | 'exif' | 'gps') => { if (ifdOffset <= 0 || ifdOffset + 2 > tiff.length) return; const count = read.u16(ifdOffset); for (let index = 0; index < count; index += 1) { const entry = ifdOffset + 2 + index * 12; if (entry + 12 > tiff.length) return; const tag = read.u16(entry); const length = read.u32(entry + 4); const inlineValue = read.u32(entry + 8); const valueOffset = length * 1 > 4 ? inlineValue : entry + 8; if (into === 'gps') { if (tag === GPS.latRef) out.latRef = read.text(entry + 8, 2); if (tag === GPS.lonRef) out.lonRef = read.text(entry + 8, 2); if (tag === GPS.lat) out.latOffset = inlineValue; if (tag === GPS.lon) out.lonOffset = inlineValue; continue; } switch (tag) { case TAGS.make: out.make = read.text(valueOffset, length); break; case TAGS.model: out.model = read.text(valueOffset, length); break; case TAGS.software: out.software = read.text(valueOffset, length); break; case TAGS.dateTime: out.dateTime = read.text(valueOffset, length); break; case TAGS.dateTimeOriginal: out.dateTimeOriginal = read.text(valueOffset, length); break; case TAGS.lensModel: out.lens = read.text(valueOffset, length); break; case TAGS.bodySerial: out.serial = read.text(valueOffset, length); break; case TAGS.exifIfd: exifOffset = inlineValue; break; case TAGS.gpsIfd: gpsOffset = inlineValue; break; default: break; } } }; walk(read.u32(4), 'root'); if (exifOffset) walk(exifOffset, 'exif'); if (gpsOffset) walk(gpsOffset, 'gps'); const result: Record = { make: out.make || undefined, model: out.model || undefined, lens: out.lens || undefined, serialNumber: out.serial || undefined, software: out.software || undefined, dateTaken: (out.dateTimeOriginal || out.dateTime || undefined) as string | undefined, }; if (out.latOffset && out.lonOffset) { const latitude = dmsToDecimal(read, out.latOffset as number, String(out.latRef ?? 'N')); const longitude = dmsToDecimal(read, out.lonOffset as number, String(out.lonRef ?? 'E')); if (Number.isFinite(latitude) && Number.isFinite(longitude) && (latitude !== 0 || longitude !== 0)) { result.gps = { latitude, longitude }; } } return result as Partial; } /* --------------------------------- JPEG ---------------------------------- */ type Segment = { marker: number; start: number; end: number; kind: string }; /** Walk a JPEG's marker segments up to the start of scan data. */ export function jpegSegments(bytes: Uint8Array): Segment[] { const segments: Segment[] = []; let offset = 2; while (offset + 4 <= bytes.length) { if (bytes[offset] !== 0xff) break; const marker = bytes[offset + 1]!; if (marker === 0xda) break; // start of scan: image data follows const length = (bytes[offset + 2]! << 8) | bytes[offset + 3]!; const start = offset; const end = offset + 2 + length; let kind = 'other'; const header = ascii(bytes, offset + 4, 6); if (marker === 0xe1 && header.startsWith('Exif')) kind = 'EXIF'; else if (marker === 0xe1 && header.startsWith('http')) kind = 'XMP'; else if (marker === 0xed) kind = 'IPTC'; else if (marker === 0xfe) kind = 'Comment'; else if (marker === 0xe2) kind = 'ICC'; segments.push({ marker, start, end, kind }); offset = end; } return segments; } const STRIPPED_JPEG_KINDS = new Set(['EXIF', 'XMP', 'IPTC', 'Comment']); /* ---------------------------------- PNG ----------------------------------- */ const PNG_METADATA_CHUNKS = new Set(['tEXt', 'iTXt', 'zTXt', 'eXIf', 'tIME']); function pngChunks(bytes: Uint8Array) { const chunks: { type: string; start: number; end: number; dataStart: number; dataEnd: number }[] = []; let offset = 8; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); while (offset + 8 <= bytes.length) { const length = view.getUint32(offset); const type = ascii(bytes, offset + 4, 4); const end = offset + 12 + length; if (end > bytes.length) break; chunks.push({ type, start: offset, end, dataStart: offset + 8, dataEnd: offset + 8 + length }); if (type === 'IEND') break; offset = end; } return chunks; } /* ---------------------------------- WebP ---------------------------------- */ function webpChunks(bytes: Uint8Array) { const chunks: { type: string; start: number; end: number; dataStart: number; dataEnd: number }[] = []; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let offset = 12; while (offset + 8 <= bytes.length) { const type = ascii(bytes, offset, 4); const length = view.getUint32(offset + 4, true); const padded = length + (length % 2); const end = offset + 8 + padded; chunks.push({ type, start: offset, end, dataStart: offset + 8, dataEnd: offset + 8 + length }); if (end > bytes.length) break; offset = end; } return chunks; } const WEBP_METADATA_CHUNKS = new Set(['EXIF', 'XMP ']); /* ------------------------------ public API -------------------------------- */ export function readMetadata(bytes: Uint8Array): ImageMetadata { const format = detectFormat(bytes); const blocks: string[] = []; let metadataBytes = 0; let tiff: Uint8Array | undefined; if (format === 'jpeg') { for (const segment of jpegSegments(bytes)) { if (!STRIPPED_JPEG_KINDS.has(segment.kind)) continue; blocks.push(segment.kind); metadataBytes += segment.end - segment.start; if (segment.kind === 'EXIF' && !tiff) tiff = bytes.subarray(segment.start + 10, segment.end); } } else if (format === 'png') { for (const chunk of pngChunks(bytes)) { if (!PNG_METADATA_CHUNKS.has(chunk.type)) continue; blocks.push(chunk.type === 'eXIf' ? 'EXIF' : chunk.type); metadataBytes += chunk.end - chunk.start; if (chunk.type === 'eXIf' && !tiff) tiff = bytes.subarray(chunk.dataStart, chunk.dataEnd); } } else if (format === 'webp') { for (const chunk of webpChunks(bytes)) { if (!WEBP_METADATA_CHUNKS.has(chunk.type)) continue; blocks.push(chunk.type.trim()); metadataBytes += chunk.end - chunk.start; if (chunk.type === 'EXIF' && !tiff) tiff = bytes.subarray(chunk.dataStart, chunk.dataEnd); } } const parsed = tiff ? parseTiff(tiff) : {}; return { format, blocks: [...new Set(blocks)], metadataBytes, ...parsed }; } /** * Remove metadata without touching the image data. * * Returns the original bytes unchanged when there is nothing to remove, so a clean file * is never needlessly rewritten. ICC colour profiles are deliberately kept: dropping one * shifts the colours on screen, which would be a visible change to the picture. */ export function stripMetadata(bytes: Uint8Array): { bytes: Uint8Array; removed: string[] } { const format = detectFormat(bytes); const removed: string[] = []; if (format === 'jpeg') { const segments = jpegSegments(bytes); const drop = segments.filter(segment => STRIPPED_JPEG_KINDS.has(segment.kind)); if (drop.length === 0) return { bytes, removed }; const keep: Uint8Array[] = [bytes.subarray(0, 2)]; let cursor = 2; for (const segment of segments) { if (!STRIPPED_JPEG_KINDS.has(segment.kind)) continue; keep.push(bytes.subarray(cursor, segment.start)); cursor = segment.end; removed.push(segment.kind); } keep.push(bytes.subarray(cursor)); return { bytes: concat(keep), removed: [...new Set(removed)] }; } if (format === 'png') { const chunks = pngChunks(bytes); const drop = chunks.filter(chunk => PNG_METADATA_CHUNKS.has(chunk.type)); if (drop.length === 0) return { bytes, removed }; const keep: Uint8Array[] = [bytes.subarray(0, 8)]; for (const chunk of chunks) { if (PNG_METADATA_CHUNKS.has(chunk.type)) { removed.push(chunk.type === 'eXIf' ? 'EXIF' : chunk.type); continue; } keep.push(bytes.subarray(chunk.start, chunk.end)); } return { bytes: concat(keep), removed: [...new Set(removed)] }; } if (format === 'webp') { const chunks = webpChunks(bytes); const drop = chunks.filter(chunk => WEBP_METADATA_CHUNKS.has(chunk.type)); if (drop.length === 0) return { bytes, removed }; const keep: Uint8Array[] = []; for (const chunk of chunks) { if (WEBP_METADATA_CHUNKS.has(chunk.type)) { removed.push(chunk.type.trim()); continue; } keep.push(bytes.subarray(chunk.start, chunk.end)); } const body = concat(keep); const header = new Uint8Array(12); header.set(bytes.subarray(0, 12)); new DataView(header.buffer).setUint32(4, body.length + 4, true); // RIFF size return { bytes: concat([header, body]), removed: [...new Set(removed)] }; } return { bytes, removed }; } function concat(parts: readonly Uint8Array[]): Uint8Array { const total = parts.reduce((sum, part) => sum + part.length, 0); const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } /** Human-readable coordinates, e.g. "51.500700, -0.124600". */ export function formatCoordinates(gps: GpsPosition): string { return `${gps.latitude.toFixed(6)}, ${gps.longitude.toFixed(6)}`; }