Skip to content

Instantly share code, notes, and snippets.

@georg-jung
Created May 5, 2025 13:54
Show Gist options
  • Save georg-jung/eb70ce22991c7d63a4113bcb0ba4e6a3 to your computer and use it in GitHub Desktop.
Save georg-jung/eb70ce22991c7d63a4113bcb0ba4e6a3 to your computer and use it in GitHub Desktop.
Rust strip-thumb by gpt o3
[package]
name = "strip_thumb"
version = "0.2.0"
edition = "2021"
[dependencies]
img-parts = "0.3"
// src/main.rs
use std::{
env,
fs::{self, File},
io,
path::Path,
};
use img_parts::jpeg::Jpeg;
use img_parts::{Bytes, ImageEXIF};
fn main() -> io::Result<()> {
/* ---------- very tiny CLI ---------- */
let mut args = env::args().skip(1); // leave program name behind
let mut verbose = false;
let path = match args.next() {
Some(flag) if flag == "-v" || flag == "--verbose" => {
verbose = true;
args.next().expect("usage: strip_thumb [-v] <file.jpg>")
}
Some(p) => p,
None => {
eprintln!("usage: strip_thumb [-v] <file.jpg>");
std::process::exit(1);
}
};
if verbose {
eprintln!("→ opening {path}");
}
let jpeg_bytes = fs::read(&path)?;
let mut jpg = Jpeg::from_bytes(jpeg_bytes.into())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
/* ---------- operate on EXIF ---------- */
match jpg.exif() {
None => {
eprintln!("Image has no EXIF block – nothing to do");
return Ok(());
}
Some(raw) => {
let mut exif = raw.as_ref().to_vec();
if !strip_thumbnail(&mut exif, verbose) {
eprintln!("No embedded thumbnail found – nothing to do");
return Ok(());
}
jpg.set_exif(Some(Bytes::from(exif)));
}
}
/* ---------- atomic write‑back ---------- */
let tmp_path = Path::new(&path).with_extension("tmp");
{
let tmp = File::create(&tmp_path)?;
jpg.encoder().write_to(tmp)?;
}
fs::rename(&tmp_path, &path)?;
println!("✔ thumbnail removed, file size reduced!");
Ok(())
}
/* ------------------------------------------------------------------------- */
/// Remove compressed **or** uncompressed thumbnails from an EXIF block.
/// With `verbose = true` the function prints every step to `stderr`.
/// Returns `true` iff a thumbnail was found and stripped.
fn strip_thumbnail(exif: &mut Vec<u8>, verbose: bool) -> bool {
macro_rules! say { ($($tt:tt)*) => { if verbose { eprintln!($($tt)*); } } }
/* ---------- locate TIFF header ---------- */
let (tiff, has_prefix) = if exif.starts_with(b"Exif\0\0") {
say!("→ APP1 starts with \"Exif\\0\\0\" (standard)");
(6, true) // TIFF header is 6 bytes in
} else {
say!("→ APP1 *does NOT* start with \"Exif\\0\\0\" – assuming TIFF starts immediately");
(0, false) // entire block is the TIFF header
};
if exif.len() < tiff + 8 {
say!("✗ EXIF block too small (<8 bytes after prefix)");
return false;
}
/* ---------- endian flag ---------- */
let le = match &exif[tiff..tiff + 2] {
b"II" => { say!("→ little‑endian TIFF"); true }
b"MM" => { say!("→ big‑endian TIFF"); false }
_ => {
say!("✗ corrupted TIFF header (no II/MM)");
return false;
}
};
/* endian helpers (stateless, so borrow issues are gone) */
let read_u16 = |buf: &[u8], off: usize| -> u16 {
if le { u16::from_le_bytes([buf[off], buf[off + 1]]) }
else { u16::from_be_bytes([buf[off], buf[off + 1]]) }
};
let read_u32 = |buf: &[u8], off: usize| -> u32 {
if le { u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) }
else { u32::from_be_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) }
};
let write_u32 = |buf: &mut [u8], off: usize, v: u32| {
let b = if le { v.to_le_bytes() } else { v.to_be_bytes() };
buf[off..off + 4].copy_from_slice(&b);
};
/* ---------- locate IFD 0 ---------- */
let ifd0_off = read_u32(&exif, tiff + 4) as usize;
say!("IFD0 @ 0x{ifd0_off:08x}");
let ifd0 = tiff + ifd0_off;
if ifd0 + 2 > exif.len() { return false; }
let n0 = read_u16(&exif, ifd0) as usize;
say!("IFD0 has {n0} entries");
let after_ifd0 = ifd0 + 2 + n0 * 12;
if after_ifd0 + 4 > exif.len() { return false; }
let ifd1_off = read_u32(&exif, after_ifd0) as usize;
if ifd1_off == 0 {
say!("✗ IFD1 pointer is 0 – no thumbnail directory");
return false;
}
let ifd1 = tiff + ifd1_off;
say!("IFD1 @ 0x{ifd1_off:08x}");
if ifd1 + 2 > exif.len() { return false; }
/* ---------- parse IFD 1 ---------- */
let n1 = read_u16(&exif, ifd1) as usize;
say!("IFD1 has {n1} entries");
if ifd1 + 2 + n1 * 12 + 4 > exif.len() { return false; }
let mut data_off: Option<usize> = None;
let mut data_len: Option<usize> = None;
for i in 0..n1 {
let ent = ifd1 + 2 + i * 12;
let tag = read_u16(&exif, ent);
let typ = read_u16(&exif, ent + 2);
let cnt = read_u32(&exif, ent + 4);
let val4 = read_u32(&exif, ent + 8); // fits for LONG & SLONG
say!(" tag 0x{tag:04x} type {typ} cnt {cnt} val 0x{val4:08x}");
match tag {
/* Compressed JPEG thumbnail */
0x0201 => { data_off = Some(val4 as usize); write_u32(exif, ent + 8, 0); }
0x0202 => { data_len = Some(val4 as usize); write_u32(exif, ent + 8, 0); }
/* Uncompressed thumbnail strips (we handle single‑strip only) */
0x0111 if cnt == 1 => {
data_off = Some(val4 as usize);
write_u32(exif, ent + 8, 0);
}
0x0117 if cnt == 1 => {
data_len = Some(val4 as usize);
write_u32(exif, ent + 8, 0);
}
_ => {}
}
}
let (start, len) = match (data_off, data_len) {
(Some(o), Some(l)) if l > 0 => (tiff + o, l),
_ => {
say!("✗ did not get BOTH offset and length – giving up");
return false;
}
};
let end = start + len;
if end > exif.len() {
say!("✗ thumbnail claims bytes past the EXIF block – corrupted?");
return false;
}
say!("Thumbnail bytes 0x{start:08x} .. 0x{end:08x} (len {len})");
/* ---------- cut the bytes out ---------- */
if end == exif.len() {
exif.truncate(start);
say!("→ truncated EXIF from 0x{start:08x}");
} else {
exif.splice(start..end, std::iter::empty());
say!("→ spliced them out (thumbnail was NOT at the end!)");
}
/* ---------- unlink IFD 1 ---------- */
write_u32(exif, after_ifd0, 0);
say!("IFD0 → IFD1 pointer zeroed");
true
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment