Skip to content

Instantly share code, notes, and snippets.

@georg-jung
Created May 5, 2025 13:54
Show Gist options
  • Select an option

  • Save georg-jung/eb70ce22991c7d63a4113bcb0ba4e6a3 to your computer and use it in GitHub Desktop.

Select an option

Save georg-jung/eb70ce22991c7d63a4113bcb0ba4e6a3 to your computer and use it in GitHub Desktop.

Revisions

  1. georg-jung created this gist May 5, 2025.
    7 changes: 7 additions & 0 deletions Cargo.toml
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,7 @@
    [package]
    name = "strip_thumb"
    version = "0.2.0"
    edition = "2021"

    [dependencies]
    img-parts = "0.3"
    194 changes: 194 additions & 0 deletions main.rs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,194 @@
    // 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
    }