/** * Utilities for reading and writing JPEG metadata (XMP/APP1 segments) * Used to embed user color in avatar images * * JPEG structure: * - Starts with FFD8 (SOI marker) * - Contains segments: FF + marker type + length (2 bytes BE) + data * - APP1 (FFE1) is used for EXIF/XMP data * - Ends with FFD9 (EOI marker) */ /* eslint-disable no-bitwise */ /* eslint-disable no-plusplus */ /* eslint-disable no-console */ /* eslint-disable no-restricted-syntax */ /* eslint-disable no-continue */ /** JPEG signature bytes */ const JPEG_SOI = new Uint8Array([0xFF, 0xD8]); // Start of Image /** XMP namespace identifier in APP1 segment */ const XMP_NAMESPACE = 'http://ns.adobe.com/xap/1.0/\0'; const XMP_NAMESPACE_BYTES = new TextEncoder().encode(XMP_NAMESPACE); /** Custom XMP namespace for paarrot */ const PAARROT_XMP_START = ''; const PAARROT_XMP_END = ''; /** * Check if data is a valid JPEG file */ function isJPEG(data: Uint8Array): boolean { if (data.length < 2) return false; return data[0] === 0xFF && data[1] === 0xD8; } /** * Read a 16-bit big-endian integer from a buffer */ function readUint16BE(data: Uint8Array, offset: number): number { return (data[offset] << 8) | data[offset + 1]; } /** * Write a 16-bit big-endian integer */ function writeUint16BE(value: number): Uint8Array { return new Uint8Array([(value >> 8) & 0xff, value & 0xff]); } /** * Create XMP data containing the color value */ function createXMPData(color: string): string { return `${PAARROT_XMP_START} ${color} ${PAARROT_XMP_END}`; } /** * Extract color from XMP data */ function extractColorFromXMP(xmpData: string): string | undefined { const match = xmpData.match(/([^<]+)<\/paarrot:color>/); if (match) { return match[1]; } return undefined; } /** * Parse JPEG segments */ function parseSegments(data: Uint8Array): Array<{ marker: number; data: Uint8Array; offset: number; length: number }> { const segments: Array<{ marker: number; data: Uint8Array; offset: number; length: number }> = []; let offset = 2; // Skip SOI marker while (offset < data.length - 1) { // Find next marker (0xFF followed by non-zero, non-0xFF byte) if (data[offset] !== 0xFF) { offset++; continue; } const marker = data[offset + 1]; // Skip padding bytes (0xFF) if (marker === 0xFF || marker === 0x00) { offset++; continue; } // EOI (End of Image) or SOS (Start of Scan) - stop parsing if (marker === 0xD9 || marker === 0xDA) { break; } // Markers without length field (RST0-RST7, SOI, EOI) if ((marker >= 0xD0 && marker <= 0xD7) || marker === 0xD8 || marker === 0xD9) { offset += 2; continue; } // Read segment length (includes the 2 length bytes) const segmentLength = readUint16BE(data, offset + 2); const segmentData = data.slice(offset + 4, offset + 2 + segmentLength); segments.push({ marker, data: segmentData, offset, length: 2 + segmentLength, // marker (2) + length field + data }); offset += 2 + segmentLength; } return segments; } /** * Check if APP1 segment contains paarrot XMP data */ function isPaarrotXMP(segmentData: Uint8Array): boolean { // Check for XMP namespace if (segmentData.length < XMP_NAMESPACE_BYTES.length) return false; for (let i = 0; i < XMP_NAMESPACE_BYTES.length; i++) { if (segmentData[i] !== XMP_NAMESPACE_BYTES[i]) return false; } const xmpString = new TextDecoder().decode(segmentData.slice(XMP_NAMESPACE_BYTES.length)); return xmpString.includes('paarrot:color') || xmpString.includes('paarrot.app'); } /** * Extract paarrot color from JPEG image data */ export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): string | undefined { const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); if (!isJPEG(data)) { return undefined; } const segments = parseSegments(data); for (const segment of segments) { // APP1 marker (0xE1) is used for XMP if (segment.marker === 0xE1) { // Check for XMP namespace if (segment.data.length >= XMP_NAMESPACE_BYTES.length) { let isXMP = true; for (let i = 0; i < XMP_NAMESPACE_BYTES.length; i++) { if (segment.data[i] !== XMP_NAMESPACE_BYTES[i]) { isXMP = false; break; } } if (isXMP) { const xmpString = new TextDecoder().decode(segment.data.slice(XMP_NAMESPACE_BYTES.length)); const color = extractColorFromXMP(xmpString); if (color) { return color; } } } } } return undefined; } /** * Embed paarrot color into JPEG image data */ export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null { const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); if (!isJPEG(data)) { return null; } const segments = parseSegments(data); // Find existing paarrot XMP segment let existingSegment: { offset: number; length: number } | null = null; for (const segment of segments) { if (segment.marker === 0xE1 && isPaarrotXMP(segment.data)) { existingSegment = { offset: segment.offset, length: segment.length }; break; } } // Create XMP APP1 segment const xmpData = createXMPData(color); const xmpBytes = new TextEncoder().encode(xmpData); const segmentData = new Uint8Array(XMP_NAMESPACE_BYTES.length + xmpBytes.length); segmentData.set(XMP_NAMESPACE_BYTES, 0); segmentData.set(xmpBytes, XMP_NAMESPACE_BYTES.length); // APP1 segment: FF E1 + length (2 bytes) + data const segmentLength = 2 + segmentData.length; // length field + data const app1Segment = new Uint8Array(4 + segmentData.length); app1Segment[0] = 0xFF; app1Segment[1] = 0xE1; app1Segment.set(writeUint16BE(segmentLength), 2); app1Segment.set(segmentData, 4); let newData: Uint8Array; if (existingSegment) { // Replace existing segment newData = new Uint8Array(data.length - existingSegment.length + app1Segment.length); newData.set(data.slice(0, existingSegment.offset), 0); newData.set(app1Segment, existingSegment.offset); newData.set( data.slice(existingSegment.offset + existingSegment.length), existingSegment.offset + app1Segment.length ); } else { // Insert after SOI marker (at offset 2) newData = new Uint8Array(data.length + app1Segment.length); newData.set(JPEG_SOI, 0); newData.set(app1Segment, 2); newData.set(data.slice(2), 2 + app1Segment.length); } return newData; } /** * Remove paarrot color from JPEG image data */ export function removeColorFromJPEG(imageData: ArrayBuffer | Uint8Array): Uint8Array | null { const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData); if (!isJPEG(data)) { return null; } const segments = parseSegments(data); // Find existing paarrot XMP segment let existingSegment: { offset: number; length: number } | null = null; for (const segment of segments) { if (segment.marker === 0xE1 && isPaarrotXMP(segment.data)) { existingSegment = { offset: segment.offset, length: segment.length }; break; } } if (!existingSegment) { return data; } // Remove the segment const newData = new Uint8Array(data.length - existingSegment.length); newData.set(data.slice(0, existingSegment.offset), 0); newData.set( data.slice(existingSegment.offset + existingSegment.length), existingSegment.offset ); return newData; }