Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 604467e480f8468389092a3b
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/lib/meta.ts
// Photo details (EXIF, XMP, IPTC, comments) inside JPEG, PNG and WebP files.
// Removing them here never touches the pixels; it only drops the blocks of the file that carry them.
type Segment = { marker: number; start: number; end: number };
/** The header segments of a JPEG, up to the start of the image data. */
function jpegSegments(b: Uint8Array): { segments: Segment[]; dataStart: number } {
const segments: Segment[] = [];
let i = 2;
while (i + 4 <= b.length && b[i] === 0xff) {
const marker = b[i + 1];
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { i += 2; continue; }
if (marker === 0xda) break; // start of scan: image data follows
const end = i + 2 + ((b[i + 2] << 8) | b[i + 3]);
segments.push({ marker, start: i, end });
i = end;
}
return { segments, dataStart: i };
}
const isExif = (b: Uint8Array, s: Segment) =>
s.marker === 0xe1 && String.fromCharCode(...b.subarray(s.start + 4, s.start + 10)) === "Exif\0\0";
/** Finds the orientation tag in an EXIF block and returns where its value sits. */
function orientationAt(b: Uint8Array, s: Segment): number | null {
const tiff = s.start + 10;
const little = b[tiff] === 0x49;
const u16 = (o: number) => (little ? b[o] | (b[o + 1] << 8) : (b[o] << 8) | b[o + 1]);
const u32 = (o: number) => (little ? (b[o] | (b[o + 1] << 8) | (b[o + 2] << 16)) + b[o + 3] * 2 ** 24 : b[o] * 2 ** 24 + ((b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]));
const ifd = tiff + u32(tiff + 4);
if (ifd + 2 > s.end) return null;
const count = u16(ifd);
for (let k = 0; k < count; k++) {
const e = ifd + 2 + k * 12;
if (e + 12 > s.end) return null;
if (u16(e) === 0x0112) return e + 8;
}
return null;
}
function readOrientation(b: Uint8Array, s: Segment): number {
const at = orientationAt(b, s);
if (at === null) return 1;
return b[s.start + 10] === 0x49 ? b[at] : b[at + 1];
}
function join(parts: Uint8Array[]): Uint8Array {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) { out.set(p, at); at += p.length; }
return out;
}
/** A minimal EXIF block holding only an orientation, so a turned photo still shows the right way up. */
function orientationOnly(o: number): Uint8Array {
// APP1, length 34, "Exif\0\0", big-endian TIFF header, one IFD entry: 0x0112 SHORT 1 = o, no next IFD.
return new Uint8Array([0xff, 0xe1, 0, 34, 0x45, 0x78, 0x69, 0x66, 0, 0, 0x4d, 0x4d, 0, 0x2a, 0, 0, 0, 8,
0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, o, 0, 0, 0, 0, 0, 0, 0, 0]);
}
/** A JPEG without its photo details. Keeps the color profile, and the orientation if the photo is turned. */
export function stripJpeg(b: Uint8Array): Uint8Array {
const { segments, dataStart } = jpegSegments(b);
const exif = segments.find((s) => isExif(b, s));
const orientation = exif ? readOrientation(b, exif) : 1;
const keep = segments.filter((s) => {
if (s.marker === 0xfe) return false; // comment
if (s.marker < 0xe1 || s.marker > 0xef) return true; // tables, frame header, JFIF
const id = String.fromCharCode(...b.subarray(s.start + 4, s.start + 18));
return (s.marker === 0xe2 && id.startsWith("ICC_PROFILE")) || (s.marker === 0xee && id.startsWith("Adobe"));
});
const parts = [b.subarray(0, 2)];
const app0 = keep[0]?.marker === 0xe0 ? keep.shift()! : null;
if (app0) parts.push(b.subarray(app0.start, app0.end));
if (orientation > 1 && orientation <= 8) parts.push(orientationOnly(orientation));
for (const s of keep) parts.push(b.subarray(s.start, s.end));
parts.push(b.subarray(dataStart));
return join(parts);
}
/** Copies the photo details of `source` into a freshly made JPEG, marked upright (its pixels already are). */
export function copyExif(made: Uint8Array, source: Uint8Array): Uint8Array {
const exif = jpegSegments(source).segments.find((s) => isExif(source, s));
if (!exif) return made;
const block = source.slice(exif.start, exif.end);
const at = orientationAt(source, exif);
if (at !== null) {
const o = at - exif.start;
if (block[10] === 0x49) { block[o] = 1; block[o + 1] = 0; } else { block[o] = 0; block[o + 1] = 1; }
}
const { segments } = jpegSegments(made);
const after = segments[0]?.marker === 0xe0 ? segments[0].end : 2;
return join([made.subarray(0, after), block, made.subarray(after)]);
}
/** A PNG without text, EXIF and time chunks. */
export function stripPng(b: Uint8Array): Uint8Array {
const drop = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME"]);
const parts = [b.subarray(0, 8)];
for (let i = 8; i + 12 <= b.length;) {
const len = ((b[i] << 24) >>> 0) + ((b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]);
const end = i + 12 + len;
if (!drop.has(String.fromCharCode(...b.subarray(i + 4, i + 8)))) parts.push(b.subarray(i, end));
i = end;
}
return join(parts);
}
/** A WebP without EXIF and XMP chunks. */
export function stripWebp(b: Uint8Array): Uint8Array {
const parts: Uint8Array[] = [];
for (let i = 12; i + 8 <= b.length;) {
const fourcc = String.fromCharCode(...b.subarray(i, i + 4));
const len = b[i + 4] | (b[i + 5] << 8) | (b[i + 6] << 16) | (b[i + 7] << 24);
const end = Math.min(b.length, i + 8 + len + (len & 1));
if (fourcc === "VP8X") {
const chunk = b.slice(i, end);
chunk[8] &= ~0x0c; // clear the EXIF and XMP flags
parts.push(chunk);
} else if (fourcc !== "EXIF" && fourcc !== "XMP ") parts.push(b.subarray(i, end));
i = end;
}
const body = join(parts);
const head = b.slice(0, 12);
new DataView(head.buffer).setUint32(4, body.length + 4, true);
return join([head, body]);
}
Change history
Quality pass: background removal, blur background, paste images
Bounded source sync
Bounded source sync
Bounded source sync