recompress_images.pl 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. #!/usr/bin/perl
  2. # Author: Trizen
  3. # Date: 13 September 2023
  4. # Edit: 08 August 2024
  5. # https://github.com/trizen
  6. # Recompress a given list of images, using either PNG or JPEG (whichever results in a smaller file size).
  7. # WARNING: the original files are deleted!
  8. # WARNING: the program does LOSSY compression of images!
  9. # If the file is a PNG image:
  10. # 1. we create a JPEG copy
  11. # 2. we recompress the PNG image using `pngquant`
  12. # 3. we recompress the JPEG copy using `jpegoptim`
  13. # 4. then we keep whichever is smaller: the PNG or the JPEG file
  14. # If the file is a JPEG image:
  15. # 1. we create a PNG copy
  16. # 2. we recompress the JPEG image using `jpegoptim`
  17. # 3. we recompress the PNG copy using `pngquant`
  18. # 4. then we keep whichever is smaller: the JPEG or the PNG file
  19. # The following tools are required:
  20. # * jpegoptim -- for recompressing JPEG images
  21. # * pngquant -- for recompressing PNG images
  22. use 5.036;
  23. use GD;
  24. use File::Find qw(find);
  25. use File::Temp qw(mktemp);
  26. use File::Copy qw(copy);
  27. use File::Spec::Functions qw(catfile tmpdir);
  28. use Getopt::Long qw(GetOptions);
  29. GD::Image->trueColor(1);
  30. my $png_only = 0; # true to recompress only PNG images
  31. my $jpeg_only = 0; # true to recompress only JPEG images
  32. my $quality = 85; # default quality value for JPEG (between 0-100)
  33. my $png_compression = 0; # default PNG compression level for GD (between 0-9)
  34. my $keep_original = 0; # true to keep original images
  35. my $use_exiftool = 0; # true to use `exiftool` instead of `File::MimeInfo::Magic`
  36. my $preserve_attr = 0; # preserve original file attributes
  37. my $suffix = ''; # recompressed filenames suffix
  38. sub png2jpeg (%args) {
  39. my $orig_file = $args{png_file} // return;
  40. my $jpeg_file = $args{jpeg_file} // return;
  41. my $image = eval { GD::Image->new($orig_file) } // do {
  42. warn "[!] Can't load file <<$orig_file>>. Skipping...\n";
  43. return;
  44. };
  45. my $jpeg_data = $image->jpeg($quality);
  46. open(my $fh, '>:raw', $jpeg_file) or do {
  47. warn "[!] Can't open file <<$jpeg_file>> for writing: $!\n";
  48. return;
  49. };
  50. print {$fh} $jpeg_data;
  51. close $fh;
  52. }
  53. sub jpeg2png (%args) {
  54. my $orig_file = $args{jpeg_file} // return;
  55. my $png_file = $args{png_file} // return;
  56. my $image = eval { GD::Image->new($orig_file) } // do {
  57. warn "[!] Can't load file <<$orig_file>>. Skipping...\n";
  58. return;
  59. };
  60. my $png_data = $image->png($png_compression);
  61. open(my $fh, '>:raw', $png_file) or do {
  62. warn "[!] Can't open file <<$png_file>> for writing: $!\n";
  63. return;
  64. };
  65. print {$fh} $png_data;
  66. close $fh;
  67. }
  68. sub determine_mime_type ($file) {
  69. if ($file =~ /\.jpe?g\z/i) {
  70. return "image/jpeg";
  71. }
  72. if ($file =~ /\.png\z/i) {
  73. return "image/png";
  74. }
  75. if ($use_exiftool) {
  76. my $res = `exiftool \Q$file\E`;
  77. $? == 0 or return;
  78. defined($res) or return;
  79. if ($res =~ m{^MIME\s+Type\s*:\s*(\S+)}mi) {
  80. return $1;
  81. }
  82. return;
  83. }
  84. require File::MimeInfo::Magic;
  85. File::MimeInfo::Magic::magic($file);
  86. }
  87. sub optimize_jpeg ($jpeg_file) {
  88. # Uncomment the following line to use `recomp-jpg` from LittleUtils
  89. # return system('recomp-jpg', '-q', '-t', $quality, $jpeg_file);
  90. system('jpegoptim', '-q', '-s', '--threshold=0.1', '-m', $quality, $jpeg_file);
  91. }
  92. sub optimize_png ($png_file) {
  93. system('pngquant', '--strip', '--ext', '.png', '--skip-if-larger', '--force', $png_file);
  94. }
  95. @ARGV or die <<"USAGE";
  96. usage: perl $0 [options] [dirs | files]
  97. Recompress a given list of images, using either PNG or JPEG (whichever results in a smaller file size).
  98. options:
  99. -q INT : quality level for JPEG (default: $quality)
  100. --jpeg : recompress only JPEG images (default: $jpeg_only)
  101. --png : recompress only PNG images (default: $png_only)
  102. --exiftool : use `exiftool` to determine the MIME type (default: $use_exiftool)
  103. --preserve : preserve original file timestamps and permissions
  104. --suffix=s : add a given suffix to recompressed filenames
  105. --keep : keep original files (to be used with --suffix)
  106. WARNING: the original files are deleted!
  107. WARNING: the program does LOSSY compression of images!
  108. USAGE
  109. GetOptions(
  110. 'q|quality=i' => \$quality,
  111. 'jpeg|jpg!' => \$jpeg_only,
  112. 'png!' => \$png_only,
  113. 'exiftool!' => \$use_exiftool,
  114. 'p|preserve!' => \$preserve_attr,
  115. 'suffix=s' => \$suffix,
  116. 'keep!' => \$keep_original,
  117. )
  118. or die "Error in command-line arguments!";
  119. my %types = (
  120. 'image/png' => {
  121. files => [],
  122. format => 'png',
  123. },
  124. 'image/jpeg' => {
  125. files => [],
  126. format => 'jpg',
  127. },
  128. );
  129. find(
  130. {
  131. no_chdir => 1,
  132. wanted => sub {
  133. (-f $_) || return;
  134. my $type = determine_mime_type($_) // return;
  135. if (exists $types{$type}) {
  136. my $ref = $types{$type};
  137. push @{$ref->{files}}, $_;
  138. }
  139. }
  140. } => @ARGV
  141. );
  142. my $total_savings = 0;
  143. my $temp_png = catfile(tmpdir(), mktemp("tmpfileXXXXX") . '.png');
  144. my $temp_jpg = catfile(tmpdir(), mktemp("tmpfileXXXXX") . '.jpg');
  145. sub recompress_image ($file, $file_format) {
  146. my $conversion_func = \&jpeg2png;
  147. my $temp_file = $temp_jpg;
  148. if ($file_format eq 'png') {
  149. $conversion_func = \&png2jpeg;
  150. $temp_file = $temp_png;
  151. }
  152. copy($file, $temp_file) or do {
  153. warn "[!] Can't copy <<$file>> to <<$temp_file>>: $!\n";
  154. return;
  155. };
  156. $conversion_func->(png_file => $temp_png, jpeg_file => $temp_jpg) or return;
  157. optimize_png($temp_png);
  158. optimize_jpeg($temp_jpg);
  159. my $final_file = $temp_png;
  160. my $file_ext = 'png';
  161. if ((-s $temp_jpg) < (-s $final_file)) {
  162. $final_file = $temp_jpg;
  163. $file_ext = 'jpg';
  164. }
  165. my $final_size = (-s $final_file);
  166. my $curr_size = (-s $file);
  167. $final_size > 0 or return;
  168. if ($final_size < $curr_size) {
  169. my $saved = ($curr_size - $final_size) / 1024;
  170. $total_savings += $saved;
  171. printf(":: Saved: %.2fKB (%.2fMB -> %.2fMB) (%.2f%%) ($file_format -> $file_ext)\n\n",
  172. $saved,
  173. $curr_size / 1024**2,
  174. $final_size / 1024**2,
  175. ($curr_size - $final_size) / $curr_size * 100);
  176. my ($dev, $ino, $mode, $nlink, $uid, $gid, $rdev, $size, $atime, $mtime, $ctime, $blksize, $blocks) = stat($file);
  177. if (not $keep_original) {
  178. unlink($file) or return;
  179. }
  180. my $new_file = ($file =~ s/\.(?:png|jpe?g)\z//ir) . $suffix . '.' . $file_ext;
  181. while (-e $new_file) { # lazy solution
  182. $new_file .= '.' . $file_ext;
  183. }
  184. copy($final_file, $new_file) or do {
  185. warn "[!] Can't copy <<$final_file>> to <<$new_file>>: $!\n";
  186. return;
  187. };
  188. # Set the original ownership of the image
  189. chown($uid, $gid, $new_file);
  190. if ($preserve_attr) {
  191. # Set the original modification time
  192. utime($atime, $mtime, $new_file)
  193. or warn "Can't change timestamp: $!\n";
  194. # Set original permissions
  195. chmod($mode & 07777, $new_file)
  196. or warn "Can't change permissions: $!\n";
  197. }
  198. }
  199. else {
  200. printf(":: The image is already very well compressed. Skipping...\n\n");
  201. }
  202. return 1;
  203. }
  204. foreach my $type (keys %types) {
  205. my $ref = $types{$type};
  206. if ($jpeg_only and $ref->{format} eq 'png') {
  207. next;
  208. }
  209. if ($png_only and $ref->{format} eq 'jpg') {
  210. next;
  211. }
  212. foreach my $file (@{$ref->{files}}) {
  213. if ($ref->{format} eq 'png') {
  214. say ":: Processing PNG file: $file";
  215. recompress_image($file, 'png');
  216. }
  217. elsif ($ref->{format} eq 'jpg') {
  218. say ":: Processing JPEG file: $file";
  219. recompress_image($file, 'jpg');
  220. }
  221. else {
  222. say "ERROR: unknown format type for file: $file";
  223. }
  224. }
  225. }
  226. unlink($temp_jpg);
  227. unlink($temp_png);
  228. printf(":: Total savings: %.2fKB\n", $total_savings),