#!/usr/bin/perl -w
#
# File:         extract_preview
#
# Revisions:    2012/06/01 - Phil Harvey Created
#               2012/06/05 - PH (v1.01) Added command-line options and fixed
#                            problem extracting previews for some Canon cameras
#               2013/08/19 - PH (v1.02) Added -a option
#               2013/10/10 - PH (v1.03) Added help message
#               2017/10/31 - PH (v1.04) Changed default to /usr/local/bin
#
# Syntax:       extract_preview [-r] FILE1 [FILE2...]
#
#               FILE - file or directory name
#
# Options:      -r     - recursively process subdirectories
#               -o DIR - write output JPEG's to specified (flat) directory
#               -a     - extract all previews, not just the largest
#
# Description:  extract largest embedded JPEG image from files or directories
#
# Example:      extract_preview -r /Users/Phil/corrupted_images
#
# Notes:        Does a brute force search of the whole file for JPEG image data
#               (slow, but effective)
#
# License:      This is free software; you can redistribute it and/or modify it
#               under the same terms as Perl itself.
#
use strict;

my $VERSION = '1.04';

my $SUFFIX  = '_preview.jpg';   # suffix for preview images

my $exeDir;
# get exe directory
$exeDir = ($0 =~ /(.*)[\\\/]/) ? $1 : '.';
# add exe directory at start of include path
unshift @INC, "$exeDir/lib";
# also check standard Mac install location and current directory
push @INC, '/usr/local/bin/lib', 'lib';

unless (eval 'require Image::ExifTool') {
    die "This scripts requires Image::ExifTool -- please install it.\n";
}

# get name of file containing list of files to process
@ARGV or die "Syntax:   extract_preview [OPTIONS] FILE1 [FILE2...]\n\n" .
             "Options:  -r     - recursively process subdirectories\n" .
             "          -o DIR - write output JPEG's to specified (flat) directory\n" .
             "          -a     - extract all previews, not just the largest\n";

print "[extract_preview version $VERSION]\n";

# static variables
my (@files, $file, $tmpFile, $recurse, $all, $outdir);

# clean up temorary files on CTRL-C
$SIG{INT} = sub {
    if ($tmpFile) {
        # erase our temporary files if they exist:
        unlink $tmpFile;                    # ours
        unlink "${tmpFile}_exiftool_tmp";   # ExifTool's
    }
    warn "\nAborted!\n";
    exit 1;
};

my $exifTool = new Image::ExifTool;
$exifTool->SetNewValue('Trailer:All');  # delete any JPEG trailer

# keep track of the statistics
my %stats;
my $already = 0;
my $count = 0;
my $errors = 0;
my $previews = 0;

while (@ARGV) {
    my $file = shift;
    if ($file !~ /^-/) {
        push @files, $file;
    } elsif ($file eq '-r') {
        $recurse = 1;
    } elsif ($file eq '-o') {
        ($outdir = shift) =~ s{[/\\]$}{};
    } elsif ($file eq '-a') {
        $all = 1;
    } else {
        die "Unknown option: $file\n";
    }
}

File:
while (@files) {
    $file = shift @files;
    # handle directory names
    if (-d $file) {
        opendir DIR, $file or die "Error opening directory $file\n";
        my @f;
        for (;;) {
            my $f = readdir DIR;
            last unless defined $f;
            next if $f =~ /^\./;    # ignore files/directories starting with "."
            next if $f =~ /\Q$SUFFIX\E$/;   # ignore previews we created
            my $path = "$file/$f";
            push @f, $path if $recurse or not -d $path;
        }
        closedir DIR;
        unshift @files, @f; # add to file list
        next;
    }
    ++$count;
    $file =~ s/^\s+//;  # trim leading whitespace
    $file =~ s/\s+$//;  # trim trailing whitespace
    print "===== $file\n";
    open FILE, $file or warn("Error opening file\n"), ++$errors, next;
    binmode FILE;

    # pre-determine the preview file name
    my $preview = $file;
    $preview =~ s{\.[^/\\]*$}{};    # remove file extension
    $preview .= $SUFFIX;            # add "_preview.jpg" to the file name
    if (defined $outdir) {
        $preview =~ s{.*[/\\]}{};   # remove directory
        $preview = "$outdir/$preview";
    }
    # loop through specified file, looking for embedded JPEG images
    my $buff = '';
    my ($saved, $buf2);
    for (;;) {
        my $n = read FILE, $buff, 1048576, length($buff);  # read 1 MB of the file
        last unless $n;        
Jpeg:   # loop through embedded JPEG candidates in this buffer
        while ($buff =~ /\xff\xd8\xff([\xdb\xe0\xe1\xc4])/sg) {
            my $bpos = pos($buff) - 4;  # position of JPEG start in buffer
            # weed out Canon compressed raw data by ignoring "\xc4\x00"
            next if $1 eq "\xc4" and (length($buff) - $bpos < 5 or substr($buff,$bpos+4,1) eq "\0");
            my $fpos = tell FILE;       # current file position
            my $tmp = "$file.ep_tmp.jpg";
            $tmpFile = $tmp;
            open OUT, ">$tmp" or die "Error creating $tmp\n";
            binmode OUT;
            print OUT substr($buff, $bpos);
            for (;;) {
                last unless read FILE, $buf2, 1048576;
                print OUT $buf2 or die "Error writing to $tmp\n";
            }
            close OUT or die "Error closing $tmp\n";
            # remove JPEG trailer data, and validate JPEG image
            my $result = $exifTool->WriteInfo($tmp);
            for (;;) {
                last unless $result;
                my $size = -s $tmp or last;
                # read back the image size to make sure it isn't random JPEG-like garbage
                my $info = $exifTool->ImageInfo($tmp, 'ImageSize', 'Warning');
                my $dim = $$info{ImageSize} or last;
                my $jpos = $fpos - length($buff) + $bpos;
                print "Found JPEG ($dim pixels, $size bytes at offset $jpos)\n";
#                $$info{Warning} and print("(ignored due to $$info{Warning})\n"), last;
                if ($all) {
                    my $name = $preview;
                    $name =~ s/\Q$SUFFIX\E$/_$dim$SUFFIX/;
                    my $count = 1;
                    while (-e $name) {
                        ++$count;
                        $name =~ s/(-\d+)?\Q$SUFFIX\E$/-$count$SUFFIX/;
                    }
                    rename $tmp, $name or die "Error renaming to $name\n";
                    $saved = ($saved || 0) + 1;
                    $stats{$dim} = ($stats{$dim} || 0) + 1;
                    ++$previews;
                } elsif (not -s $preview or $size > -s $preview) {
                    rename $tmp, $preview or die "Error renaming to $preview\n";
                    $saved = $dim;
                } else {
                    unlink $tmp;    # erase the smaller temporary file
                    $saved = 0 unless defined $saved;
                }
                undef $tmpFile;
                $buff = '';         # force re-loading of buffer
                my $jend = $jpos + $size;
                seek FILE, $jend, 0 or die "Error seeking to $jend in $file\n";
                last Jpeg;
            }
            unlink $tmp;    # erase temporary file (not a valid JPEG)
            undef $tmpFile;
            # continue reading file where we left off
            seek FILE, $fpos, 0 or die "Error seeking to $fpos in $file\n";
        }
        # no more JPEG-like headers in this buffer, so keep the last few
        # bytes for the next match and go around to load another buffer
        if (length($buff) > 3) {
            $buff = substr $buff, -3;   # keep last 3 bytes from buffer 
        } else {
            $buff = '';
        }
    }
    close FILE;
    if ($saved) {
        if ($all) {
            print "  --> saved $saved previews\n";
        } else {
            print "  --> saved $saved preview as $preview\n";
            $stats{$saved} = ($stats{$saved} || 0) + 1;
            ++$previews;
        }
    } elsif (defined $saved) {
        ++$already;
    }
}

# print summary statistics
my @dims = sort { $a=~/^(\d+)/; my $wid=$1; $b=~/^(\d+)/; return $wid <=> $1 } keys %stats;
printf("----- Summary\n");
printf("%5d files not processed due to errors\n", $errors) if $errors;
printf("%5d files processed\n", $count);
printf("%5d previews already existed\n", $already) if $already;
printf("%5d previews created:\n", $previews);
printf("    %5d $_\n", $stats{$_}) foreach @dims;
printf("Done.\n");

# end
