From 28e812f3f60f18c30510274a95330633e8e104d6 Mon Sep 17 00:00:00 2001 From: Aaron Elkiss Date: Tue, 16 Sep 2025 09:17:14 -0400 Subject: [PATCH 1/3] fix indentation for local_ingest.t --- t/local_ingest.t | 422 +++++++++++++++++++++++------------------------ 1 file changed, 211 insertions(+), 211 deletions(-) diff --git a/t/local_ingest.t b/t/local_ingest.t index 4b59be49..0c10835e 100644 --- a/t/local_ingest.t +++ b/t/local_ingest.t @@ -10,263 +10,263 @@ use HTFeed::Test::Support qw(load_db_fixtures); use Test::Spec; sub unpacked_volume { - my $objid = shift; - my $volume = HTFeed::Volume->new( - namespace => 'test', - objid => $objid, - packagetype => 'simple' - ); + my $objid = shift; + my $volume = HTFeed::Volume->new( + namespace => 'test', + objid => $objid, + packagetype => 'simple' + ); - HTFeed::PackageType::Simple::Unpack->new(volume => $volume)->run(); + HTFeed::PackageType::Simple::Unpack->new(volume => $volume)->run(); - return $volume; + return $volume; } sub unpack_and_verify { - my $objid = shift; - my $volume = unpacked_volume($objid); - my $stage = HTFeed::PackageType::Simple::VerifyManifest->new(volume => $volume); - $stage->run; - return $stage; + my $objid = shift; + my $volume = unpacked_volume($objid); + my $stage = HTFeed::PackageType::Simple::VerifyManifest->new(volume => $volume); + $stage->run; + return $stage; } describe "HTFeed::PackageType::Simple" => sub { - my $tmpdirs; - my $testlog; + my $tmpdirs; + my $testlog; + + before all => sub { + load_db_fixtures; + $tmpdirs = HTFeed::Test::TempDirs->new(); + $testlog = HTFeed::Test::Logger->new(); + set_config(0, 'stop_on_error'); + }; + + before each => sub { + $tmpdirs->setup_example; + $testlog->reset; + set_config($tmpdirs->test_home . "/fixtures/simple", 'staging', 'fetch'); + }; + + after each => sub { + $tmpdirs->cleanup_example; + }; + + after all => sub { + $tmpdirs->cleanup; + }; + + describe "checksum.md5" => sub { + it "reports a relevant error when checksum.md5 is missing" => sub { + eval { unpack_and_verify("no_checksum"); }; + printf STDERR "EVAL STATUS: $@\n"; + ok($testlog->matches(qr(Missing file.*checksum.md5))); + }; - before all => sub { - load_db_fixtures; - $tmpdirs = HTFeed::Test::TempDirs->new(); - $testlog = HTFeed::Test::Logger->new(); - set_config(0, 'stop_on_error'); + it "reports relevant errors when checksum.md5 is empty" => sub { + unpack_and_verify("empty_checksum"); + ok($testlog->matches(qr(present in package but not in checksum file))); }; - before each => sub { - $tmpdirs->setup_example; - $testlog->reset; - set_config($tmpdirs->test_home . "/fixtures/simple", 'staging', 'fetch'); + it "reports the specific files missing from checksum.md5" => sub { + unpack_and_verify("missing_meta_yml_checksum"); + ok($testlog->matches(qr(file: meta\.yml.*present in package but not in checksum file))); }; + }; - after each => sub { - $tmpdirs->cleanup_example; + describe "thumbs.db" => sub { + it "ignores Thumbs.db when it is in the checksum file but not the package" => sub { + ok(unpack_and_verify("thumbs_in_checksum")->succeeded()); }; - after all => sub { - $tmpdirs->cleanup; + it "ignores Thumbs.db when it is in the package but not the checksum file" => sub { + ok(unpack_and_verify("thumbs_in_pkg")->succeeded()); }; - describe "checksum.md5" => sub { - it "reports a relevant error when checksum.md5 is missing" => sub { - eval { unpack_and_verify("no_checksum"); }; - printf STDERR "EVAL STATUS: $@\n"; - ok($testlog->matches(qr(Missing file.*checksum.md5))); - }; - - it "reports relevant errors when checksum.md5 is empty" => sub { - unpack_and_verify("empty_checksum"); - ok($testlog->matches(qr(present in package but not in checksum file))); - }; - - it "reports the specific files missing from checksum.md5" => sub { - unpack_and_verify("missing_meta_yml_checksum"); - ok($testlog->matches(qr(file: meta\.yml.*present in package but not in checksum file))); - }; + it "ignores Thumbs.db when it is in the checksum file and the package, but the checksum is wrong" => sub { + ok(unpack_and_verify("thumbs_bad_checksum")->succeeded()); }; - describe "thumbs.db" => sub { - it "ignores Thumbs.db when it is in the checksum file but not the package" => sub { - ok(unpack_and_verify("thumbs_in_checksum")->succeeded()); - }; + it "ignores Thumbs.db when it is in both the checksum file and the package" => sub { + ok(unpack_and_verify("thumbs_in_pkg_and_checksum")->succeeded()); + }; + }; - it "ignores Thumbs.db when it is in the package but not the checksum file" => sub { - ok(unpack_and_verify("thumbs_in_pkg")->succeeded()); - }; + describe "meta.yml" => sub { + before all => sub { + mock_zephir(); + }; - it "ignores Thumbs.db when it is in the checksum file and the package, but the checksum is wrong" => sub { - ok(unpack_and_verify("thumbs_bad_checksum")->succeeded()); - }; + it "reports a relevant error when meta.yml is missing" => sub { + my $volume = unpacked_volume("no_meta_yml"); + eval { HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); }; - it "ignores Thumbs.db when it is in both the checksum file and the package" => sub { - ok(unpack_and_verify("thumbs_in_pkg_and_checksum")->succeeded()); - }; + ok($testlog->matches(qr(Missing file.*meta\.yml))); }; - describe "meta.yml" => sub { - before all => sub { - mock_zephir(); - }; + it "reports a relevant error when meta.yml is malformed" => sub { + my $volume = unpacked_volume("bad_meta_yml"); + eval { HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); }; + ok($testlog->matches(qr(File validation failed.*meta\.yml)s)); + } + }; - it "reports a relevant error when meta.yml is missing" => sub { - my $volume = unpacked_volume("no_meta_yml"); - eval { HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); }; + describe "HTFeed::PackageType::Simple::ImageRemediate" => sub { + it "compresses tif to a valid jpeg2000" => sub { + my $volume = unpacked_volume("rgb_tif"); + my $remediate = HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume); + $remediate->run(); - ok($testlog->matches(qr(Missing file.*meta\.yml))); - }; + ok(-e "$tmpdirs->{ingest}/rgb_tif/00000001.jp2"); + ok($remediate->succeeded()); - it "reports a relevant error when meta.yml is malformed" => sub { - my $volume = unpacked_volume("bad_meta_yml"); - eval { HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); }; - ok($testlog->matches(qr(File validation failed.*meta\.yml)s)); - } - }; + HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); - describe "HTFeed::PackageType::Simple::ImageRemediate" => sub { - it "compresses tif to a valid jpeg2000" => sub { - my $volume = unpacked_volume("rgb_tif"); - my $remediate = HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume); - $remediate->run(); - - ok(-e "$tmpdirs->{ingest}/rgb_tif/00000001.jp2"); - ok($remediate->succeeded()); - - HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); - - my $validate = HTFeed::VolumeValidator->new(volume => $volume); - $validate->run(); - ok($validate->succeeded()); - }; - - it "preserves XMP values when compressing tif" => sub { - my $volume = unpacked_volume("rgb_tif"); - my $remediate = HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume); - $remediate->run(); - - my $exiftool = Image::ExifTool->new(); - $exiftool->ExtractInfo("$tmpdirs->{ingest}/rgb_tif/00000001.jp2"); - is($exiftool->GetValue("XMP-tiff:Make"), "Test scanner make"); - }; - - it "recompresses lossless jpeg2000 to a valid jpeg2000" => sub { - my $volume = unpacked_volume("lossless_jp2"); - - HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); - HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); - - my $validate = HTFeed::VolumeValidator->new(volume => $volume); - $validate->run();; - ok($validate->succeeded()); - }; - - it "preserves the XMP when recompressing a lossless JPEG2000" => sub { - # jp2 has artist & resolution fields in XMP; should preserve those - my $volume = unpacked_volume("lossless_jp2_with_xmp"); - HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); - HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); - - my $validate = HTFeed::VolumeValidator->new(volume => $volume); - $validate->run(); - ok($validate->succeeded()); - - my $exiftool = Image::ExifTool->new(); - $exiftool->ExtractInfo("$tmpdirs->{ingest}/lossless_jp2_with_xmp/00000001.jp2"); - is($exiftool->GetValue("XMP-tiff:Make"), "Test scanner make"); - }; - - it "does not lose artist when compressing a bitonal tiff" => sub { - my $volume = unpacked_volume("bitonal_tiff"); - HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); - HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); - my $validate = HTFeed::VolumeValidator->new(volume => $volume); - $validate->run(); - ok($validate->succeeded()); - }; + my $validate = HTFeed::VolumeValidator->new(volume => $volume); + $validate->run(); + ok($validate->succeeded()); }; -}; -describe "HTFeed::PackageType::Simple::Download" => sub { - use HTFeed::PackageType::Simple::Download; - my $tmpdirs; - my $testlog; - my $save_rclone; + it "preserves XMP values when compressing tif" => sub { + my $volume = unpacked_volume("rgb_tif"); + my $remediate = HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume); + $remediate->run(); - before all => sub { - load_db_fixtures; - $tmpdirs = HTFeed::Test::TempDirs->new(); - $testlog = HTFeed::Test::Logger->new(); - set_config(0, 'stop_on_error'); - set_config(1, 'use_dropbox'); - set_config($tmpdirs->test_home . "/fixtures/rclone_config.conf", 'rclone_config_path'); - set_config("$FindBin::Bin/bin/rclone_stub.pl", 'rclone'); + my $exiftool = Image::ExifTool->new(); + $exiftool->ExtractInfo("$tmpdirs->{ingest}/rgb_tif/00000001.jp2"); + is($exiftool->GetValue("XMP-tiff:Make"), "Test scanner make"); }; - before each => sub { - $tmpdirs->setup_example; - $testlog->reset; - }; + it "recompresses lossless jpeg2000 to a valid jpeg2000" => sub { + my $volume = unpacked_volume("lossless_jp2"); - after each => sub { - $tmpdirs->cleanup_example; - }; + HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); + HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); - after all => sub { - $tmpdirs->cleanup; - set_config(0, 'use_dropbox'); + my $validate = HTFeed::VolumeValidator->new(volume => $volume); + $validate->run();; + ok($validate->succeeded()); }; - describe "download stage" => sub { - it "downloads the file" => sub { - my $volume = HTFeed::Volume->new( - namespace => 'test', - objid => 'test_objid', - packagetype => 'simple' - ); - my $download = $volume->get_sip_location(); - my $stage = HTFeed::PackageType::Simple::Download->new(volume => $volume); - $stage->run(); - ok($stage->succeeded() && -f $download); - }; - }; -}; + it "preserves the XMP when recompressing a lossless JPEG2000" => sub { + # jp2 has artist & resolution fields in XMP; should preserve those + my $volume = unpacked_volume("lossless_jp2_with_xmp"); + HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); + HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); -describe "HTFeed::PackageType::Simple::Volume" => sub { - use HTFeed::PackageType::Simple::Download; - my $tmpdirs; - my $testlog; - my $fetchdir; - - before all => sub { - load_db_fixtures; - $tmpdirs = HTFeed::Test::TempDirs->new(); - $testlog = HTFeed::Test::Logger->new(); - set_config(0, 'stop_on_error'); - set_config(1, 'use_dropbox'); - set_config($tmpdirs->test_home . "/fixtures/rclone_config.conf", 'rclone_config_path'); - set_config("$FindBin::Bin/bin/rclone_stub.pl", 'rclone'); - }; + my $validate = HTFeed::VolumeValidator->new(volume => $volume); + $validate->run(); + ok($validate->succeeded()); - before each => sub { - $tmpdirs->setup_example; - $testlog->reset; - $fetchdir = $tmpdirs->dir_for("fetch"); - set_config($fetchdir, 'staging', 'fetch'); - mkdir("$fetchdir/test"); - system("touch", "$fetchdir/test/test_objid.zip"); - system("touch", "$fetchdir/test/test_objid.xml"); + my $exiftool = Image::ExifTool->new(); + $exiftool->ExtractInfo("$tmpdirs->{ingest}/lossless_jp2_with_xmp/00000001.jp2"); + is($exiftool->GetValue("XMP-tiff:Make"), "Test scanner make"); }; - after each => sub { - $tmpdirs->cleanup_example; - remove_tree($fetchdir); + it "does not lose artist when compressing a bitonal tiff" => sub { + my $volume = unpacked_volume("bitonal_tiff"); + HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); + HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); + my $validate = HTFeed::VolumeValidator->new(volume => $volume); + $validate->run(); + ok($validate->succeeded()); }; + }; +}; - after all => sub { - $tmpdirs->cleanup; - set_config(0, 'use_dropbox'); +describe "HTFeed::PackageType::Simple::Download" => sub { + use HTFeed::PackageType::Simple::Download; + my $tmpdirs; + my $testlog; + my $save_rclone; + + before all => sub { + load_db_fixtures; + $tmpdirs = HTFeed::Test::TempDirs->new(); + $testlog = HTFeed::Test::Logger->new(); + set_config(0, 'stop_on_error'); + set_config(1, 'use_dropbox'); + set_config($tmpdirs->test_home . "/fixtures/rclone_config.conf", 'rclone_config_path'); + set_config("$FindBin::Bin/bin/rclone_stub.pl", 'rclone'); + }; + + before each => sub { + $tmpdirs->setup_example; + $testlog->reset; + }; + + after each => sub { + $tmpdirs->cleanup_example; + }; + + after all => sub { + $tmpdirs->cleanup; + set_config(0, 'use_dropbox'); + }; + + describe "download stage" => sub { + it "downloads the file" => sub { + my $volume = HTFeed::Volume->new( + namespace => 'test', + objid => 'test_objid', + packagetype => 'simple' + ); + my $download = $volume->get_sip_location(); + my $stage = HTFeed::PackageType::Simple::Download->new(volume => $volume); + $stage->run(); + ok($stage->succeeded() && -f $download); }; + }; +}; - describe "#clean_sip_success" => sub { - it "calls rclone to remove SIP from Dropbox" => sub { - my $volume = HTFeed::Volume->new( - namespace => 'test', - objid => 'test_objid', - packagetype => 'simple' - ); - eval { - $volume->clean_sip_success(); - }; - ok($testlog->matches(qr(running.+?rclone.+?delete)i) && !$@); - }; +describe "HTFeed::PackageType::Simple::Volume" => sub { + use HTFeed::PackageType::Simple::Download; + my $tmpdirs; + my $testlog; + my $fetchdir; + + before all => sub { + load_db_fixtures; + $tmpdirs = HTFeed::Test::TempDirs->new(); + $testlog = HTFeed::Test::Logger->new(); + set_config(0, 'stop_on_error'); + set_config(1, 'use_dropbox'); + set_config($tmpdirs->test_home . "/fixtures/rclone_config.conf", 'rclone_config_path'); + set_config("$FindBin::Bin/bin/rclone_stub.pl", 'rclone'); + }; + + before each => sub { + $tmpdirs->setup_example; + $testlog->reset; + $fetchdir = $tmpdirs->dir_for("fetch"); + set_config($fetchdir, 'staging', 'fetch'); + mkdir("$fetchdir/test"); + system("touch", "$fetchdir/test/test_objid.zip"); + system("touch", "$fetchdir/test/test_objid.xml"); + }; + + after each => sub { + $tmpdirs->cleanup_example; + remove_tree($fetchdir); + }; + + after all => sub { + $tmpdirs->cleanup; + set_config(0, 'use_dropbox'); + }; + + describe "#clean_sip_success" => sub { + it "calls rclone to remove SIP from Dropbox" => sub { + my $volume = HTFeed::Volume->new( + namespace => 'test', + objid => 'test_objid', + packagetype => 'simple' + ); + eval { + $volume->clean_sip_success(); + }; + ok($testlog->matches(qr(running.+?rclone.+?delete)i) && !$@); }; + }; }; runtests unless caller; From 41aca9ed3648751f6831ce31d4ea3afb8e42e87f Mon Sep 17 00:00:00 2001 From: Aaron Elkiss Date: Thu, 18 Sep 2025 14:47:29 -0400 Subject: [PATCH 2/3] convert tabs to spaces in imageremediate --- lib/HTFeed/Stage/ImageRemediate.pm | 2104 ++++++++++++++-------------- 1 file changed, 1052 insertions(+), 1052 deletions(-) diff --git a/lib/HTFeed/Stage/ImageRemediate.pm b/lib/HTFeed/Stage/ImageRemediate.pm index f88886a7..8082e5c9 100644 --- a/lib/HTFeed/Stage/ImageRemediate.pm +++ b/lib/HTFeed/Stage/ImageRemediate.pm @@ -31,7 +31,7 @@ The class provides methods for cleaning up image files prior to ingest. =cut sub run { - die("Subclass must implement run."); + die("Subclass must implement run."); } =item get_exiftool_fields() @@ -45,26 +45,26 @@ $fields_ref = get_exiftool_fields($file) =cut sub get_exiftool_fields { - require Image::ExifTool; - - my $self = shift; - my $file = shift; - my $fields = {}; - - my $exifTool = new Image::ExifTool; - # if it can't make a valid file jhove will complain later - $exifTool->Options('IgnoreMinorErrors' => 1); - $exifTool->Options('ScanForXMP' => 1); - $exifTool->ExtractInfo($file, { Binary => 1 }); - - foreach my $tag ($exifTool->GetFoundTags()) { - # get only the groupname we'll use to update it later - my $group = $exifTool->GetGroup($tag, "1"); - my $tagname = Image::ExifTool::GetTagName($tag); - $fields->{"$group:$tagname"} = $exifTool->GetValue($tag); - } - - return $fields; + require Image::ExifTool; + + my $self = shift; + my $file = shift; + my $fields = {}; + + my $exifTool = new Image::ExifTool; + # if it can't make a valid file jhove will complain later + $exifTool->Options('IgnoreMinorErrors' => 1); + $exifTool->Options('ScanForXMP' => 1); + $exifTool->ExtractInfo($file, { Binary => 1 }); + + foreach my $tag ($exifTool->GetFoundTags()) { + # get only the groupname we'll use to update it later + my $group = $exifTool->GetGroup($tag, "1"); + my $tagname = Image::ExifTool::GetTagName($tag); + $fields->{"$group:$tagname"} = $exifTool->GetValue($tag); + } + + return $fields; } =item remediate_image() @@ -103,24 +103,24 @@ specified in pixels per inch. =cut sub remediate_image { - my $self = shift; - my $oldfile = shift; - # dispatch to appropriate remediator - $oldfile =~ /\.(.+?)$/; - my $oldext = $1; - # Possibly plug in other extension-specific remediators here? - if ($oldext eq "jp2") { - return $self->_remediate_jpeg2000($oldfile, @_); - } elsif ($oldext eq "tif") { - return $self->_remediate_tiff($oldfile, @_); - } - - # And if we didn't return anything above, that's an error. - $self->set_error( - "BadFile", - file => $oldfile, - detail => "Unknown image format ($oldext); can't remediate" - ); + my $self = shift; + my $oldfile = shift; + # dispatch to appropriate remediator + $oldfile =~ /\.(.+?)$/; + my $oldext = $1; + # Possibly plug in other extension-specific remediators here? + if ($oldext eq "jp2") { + return $self->_remediate_jpeg2000($oldfile, @_); + } elsif ($oldext eq "tif") { + return $self->_remediate_tiff($oldfile, @_); + } + + # And if we didn't return anything above, that's an error. + $self->set_error( + "BadFile", + file => $oldfile, + detail => "Unknown image format ($oldext); can't remediate" + ); } =item update_tags() @@ -132,27 +132,27 @@ $self->update_tags($exifTool,$outfile); =cut sub update_tags { - my $self = shift; - my $exifTool = shift; - my $outfile = shift; - my $infile = shift; + my $self = shift; + my $exifTool = shift; + my $outfile = shift; + my $infile = shift; - my $res; + my $res; - if (defined $infile) { - $res = $exifTool->WriteInfo($infile, $outfile); - } else { - $res = $exifTool->WriteInfo($outfile); - } + if (defined $infile) { + $res = $exifTool->WriteInfo($infile, $outfile); + } else { + $res = $exifTool->WriteInfo($outfile); + } - if (!$res) { - $self->set_error( - "OperationFailed", - operation => "exiftool write", - file => "$outfile", - detail => $exifTool->GetValue('Error') - ); - } + if (!$res) { + $self->set_error( + "OperationFailed", + operation => "exiftool write", + file => "$outfile", + detail => $exifTool->GetValue('Error') + ); + } } =item copy_old_to_new() @@ -165,551 +165,551 @@ $self->copy_old_to_new($oldFieldName, $newFieldName); =cut sub copy_old_to_new($$$) { - my $self = shift; - my $oldFieldName = shift; - my $newFieldName = shift; - - my $oldValue = $self->{oldFields}->{$oldFieldName}; - if ( - defined $self->{oldFields}->{$oldFieldName} and - not defined $self->{newFields}->{$newFieldName} - ) { - $self->{newFields}->{$newFieldName} = $oldValue; - } + my $self = shift; + my $oldFieldName = shift; + my $newFieldName = shift; + + my $oldValue = $self->{oldFields}->{$oldFieldName}; + if ( + defined $self->{oldFields}->{$oldFieldName} and + not defined $self->{newFields}->{$newFieldName} + ) { + $self->{newFields}->{$newFieldName} = $oldValue; + } } =item set_new_if_undefined() -Copies old field value to the new field value, but only if the old value is defined -and the new one isn't. +Set the field to the given value, but only if the old field is missing or empty +(i.e. there isn't already a value there) $self->set_new_if_undefined($newFieldName,$newFieldVal); =cut sub set_new_if_undefined($$$) { - my $self = shift; - my $newFieldName = shift; - my $newFieldVal = shift; - - if ( - not defined $self->{oldFields}->{$newFieldName} - or $self->{oldFields}->{$newFieldName} eq '' - ) { - $self->{newFields}->{$newFieldName} = $newFieldVal; - } + my $self = shift; + my $newFieldName = shift; + my $newFieldVal = shift; + + if ( + not defined $self->{oldFields}->{$newFieldName} + or $self->{oldFields}->{$newFieldName} eq '' + ) { + $self->{newFields}->{$newFieldName} = $newFieldVal; + } } sub stage_info { - return { - success_state => 'images_remediated', - failure_state => '' - }; + return { + success_state => 'images_remediated', + failure_state => '' + }; } sub _remediate_tiff { - my $self = shift; - my $infile = shift; - my $outfile = shift; - my $force_headers = shift || {}; - my $set_if_undefined_headers = shift; - - my $infile_size = -s $infile; - - my $bad = 0; - my $remediate_imagemagick = 0; #needs imagemagick fix - - $self->{newFields} = $force_headers; - $self->{oldFields} = $self->get_exiftool_fields($infile); - my $fields = $self->{oldFields}; - - my $status = $self->{jhoveStatus}; - if (not defined $status) { - croak("No Status field for $infile, not remediable (did JHOVE run properly?)\n"); - $bad = 1; - } elsif ($status ne 'Well-Formed and valid') { - foreach my $error (@{ $self->{jhoveErrors} }) { - # Is the error remediable? - my @exiftool_remediable_errs = ( - 'IFD offset not word-aligned', - 'Value offset not word-aligned', - 'Tag 269 out of sequence', - 'Invalid DateTime separator', - 'Invalid DateTime digit', - 'Invalid DateTime length', - 'FocalPlaneResolutionUnit value out of range', - 'Count mismatch for tag 306', # DateTime -- fixable - 'Count mismatch for tag 36867' # EXIF DateTimeOriginal - ignorable - - ); - my @imagemagick_remediable_errs = ( - 'PhotometricInterpretation not defined', - 'ColorSpace value out of range: 2', - 'WhiteBalance value out of range: 4', - 'WhiteBalance value out of range: 5', - # wrong data type for tag - will get automatically stripped - 'Type mismatch for tag', - # related to thumbnails, which imagemagick will strip - 'JPEGProc not defined for JPEG compression', - # related to ICC profiles, which imagemagick will strip - 'Bad ICCProfile in tag 34675' - ); - - if (grep { $error =~ /^$_/ } @imagemagick_remediable_errs) { - get_logger()->trace( - "PREVALIDATE_REMEDIATE: $infile has remediable error '$error'\n" - ); - $remediate_imagemagick = 1; - } elsif (grep { $error =~ /^$_/ } @exiftool_remediable_errs) { - get_logger()->trace( - "PREVALIDATE_REMEDIATE: $infile has remediable error '$error'\n" - ); - } else { - $self->set_error( - "BadFile", - file => $infile, - detail => "Nonremediable error '$error'" - ); - $bad = 1; - } - } - } - - # Does it look like a contone? Bail & convert to JPEG2000 if so. - if (!$bad and (is_rgb_tiff($fields) or is_grayscale_tiff($fields))) { - $infile = basename($infile); - my ($seq) = ($infile =~ /^(.*).tif$/); - return $self->convert_tiff_to_jpeg2000($seq); - } - - if ($self->{newFields}{DateTime}) { - my $new_date = $self->{newFields}{DateTime}; - $self->set_new_date_fields($new_date, $new_date); - delete $self->{newFields}{'DateTime'}; - } else { - $self->fix_datetime($set_if_undefined_headers->{'DateTime'}); - delete $set_if_undefined_headers->{'DateTime'} + my $self = shift; + my $infile = shift; + my $outfile = shift; + my $force_headers = shift || {}; + my $set_if_undefined_headers = shift; + + my $infile_size = -s $infile; + + my $bad = 0; + my $remediate_imagemagick = 0; #needs imagemagick fix + + $self->{newFields} = $force_headers; + $self->{oldFields} = $self->get_exiftool_fields($infile); + my $fields = $self->{oldFields}; + + my $status = $self->{jhoveStatus}; + if (not defined $status) { + croak("No Status field for $infile, not remediable (did JHOVE run properly?)\n"); + $bad = 1; + } elsif ($status ne 'Well-Formed and valid') { + foreach my $error (@{ $self->{jhoveErrors} }) { + # Is the error remediable? + my @exiftool_remediable_errs = ( + 'IFD offset not word-aligned', + 'Value offset not word-aligned', + 'Tag 269 out of sequence', + 'Invalid DateTime separator', + 'Invalid DateTime digit', + 'Invalid DateTime length', + 'FocalPlaneResolutionUnit value out of range', + 'Count mismatch for tag 306', # DateTime -- fixable + 'Count mismatch for tag 36867' # EXIF DateTimeOriginal - ignorable + + ); + my @imagemagick_remediable_errs = ( + 'PhotometricInterpretation not defined', + 'ColorSpace value out of range: 2', + 'WhiteBalance value out of range: 4', + 'WhiteBalance value out of range: 5', + # wrong data type for tag - will get automatically stripped + 'Type mismatch for tag', + # related to thumbnails, which imagemagick will strip + 'JPEGProc not defined for JPEG compression', + # related to ICC profiles, which imagemagick will strip + 'Bad ICCProfile in tag 34675' + ); + + if (grep { $error =~ /^$_/ } @imagemagick_remediable_errs) { + get_logger()->trace( + "PREVALIDATE_REMEDIATE: $infile has remediable error '$error'\n" + ); + $remediate_imagemagick = 1; + } elsif (grep { $error =~ /^$_/ } @exiftool_remediable_errs) { + get_logger()->trace( + "PREVALIDATE_REMEDIATE: $infile has remediable error '$error'\n" + ); + } else { + $self->set_error( + "BadFile", + file => $infile, + detail => "Nonremediable error '$error'" + ); + $bad = 1; + } } - - # Fix resolution, if needed - my $force_res = $self->{newFields}{'Resolution'}; - if (defined($force_res)) { - $self->{newFields}{'IFD0:ResolutionUnit'} = 'inch'; - $self->{newFields}{'IFD0:XResolution'} = $force_res; - $self->{newFields}{'IFD0:YResolution'} = $force_res; - delete $self->{newFields}{Resolution}; + } + + # Does it look like a contone? Bail & convert to JPEG2000 if so. + if (!$bad and (is_rgb_tiff($fields) or is_grayscale_tiff($fields))) { + $infile = basename($infile); + my ($seq) = ($infile =~ /^(.*).tif$/); + return $self->convert_tiff_to_jpeg2000($seq); + } + + if ($self->{newFields}{DateTime}) { + my $new_date = $self->{newFields}{DateTime}; + $self->set_new_date_fields($new_date, $new_date); + delete $self->{newFields}{'DateTime'}; + } else { + $self->fix_datetime($set_if_undefined_headers->{'DateTime'}); + delete $set_if_undefined_headers->{'DateTime'} + } + + # Fix resolution, if needed + my $force_res = $self->{newFields}{'Resolution'}; + if (defined($force_res)) { + $self->{newFields}{'IFD0:ResolutionUnit'} = 'inch'; + $self->{newFields}{'IFD0:XResolution'} = $force_res; + $self->{newFields}{'IFD0:YResolution'} = $force_res; + delete $self->{newFields}{Resolution}; + } + + # Breaking out some conditions, choosing short var names over long lines. + my $bps_is_one = $fields->{'IFD0:BitsPerSample'} eq '1'; + my $spp_is_one = $fields->{'IFD0:SamplesPerPixel'} eq '1'; + my $piw_is_one = $self->prevalidate_field('IFD0:PhotometricInterpretation', 'WhiteIsZero', 1); + my $cmp_is_one = $self->prevalidate_field('IFD0:Compression', 'T6/Group 4 Fax', 1); + my $ftt_is_zero = $self->prevalidate_field('File:FileType', 'TIFF', 0); + my $ohn_is_one = $self->prevalidate_field('IFD0:Orientation', 'Horizontal (normal)', 1); + + # Prevalidate other fields for bitonal images + if (!$bad and $bps_is_one and $spp_is_one) { + $remediate_imagemagick = 1 unless $piw_is_one; + $remediate_imagemagick = 1 unless $cmp_is_one; + if (!$ftt_is_zero) { + $bad = 1; + $self->set_error( + "BadValue", + field => "File:FileType", + actual => $self->{oldFields}{'File:FileType'}, + expected => 'TIFF' + ); } - - # Breaking out some conditions, choosing short var names over long lines. - my $bps_is_one = $fields->{'IFD0:BitsPerSample'} eq '1'; - my $spp_is_one = $fields->{'IFD0:SamplesPerPixel'} eq '1'; - my $piw_is_one = $self->prevalidate_field('IFD0:PhotometricInterpretation', 'WhiteIsZero', 1); - my $cmp_is_one = $self->prevalidate_field('IFD0:Compression', 'T6/Group 4 Fax', 1); - my $ftt_is_zero = $self->prevalidate_field('File:FileType', 'TIFF', 0); - my $ohn_is_one = $self->prevalidate_field('IFD0:Orientation', 'Horizontal (normal)', 1); - - # Prevalidate other fields for bitonal images - if (!$bad and $bps_is_one and $spp_is_one) { - $remediate_imagemagick = 1 unless $piw_is_one; - $remediate_imagemagick = 1 unless $cmp_is_one; - if (!$ftt_is_zero) { - $bad = 1; - $self->set_error( - "BadValue", - field => "File:FileType", - actual => $self->{oldFields}{'File:FileType'}, - expected => 'TIFF' - ); - } - if (!$ohn_is_one) { - $self->{newFields}{'IFD0:Orientation'} = 'Horizontal (normal)'; - } + if (!$ohn_is_one) { + $self->{newFields}{'IFD0:Orientation'} = 'Horizontal (normal)'; } - - my $ret = !$bad; - if ($remediate_imagemagick and !$bad) { - # return true if remediation succeeds - $ret = $self->repair_tiff_imagemagick($infile, $outfile); - - # repair the correct one when setting new headers - $infile = $outfile; - } - - while (my ($field, $val) = each(%$set_if_undefined_headers)) { - $self->set_new_if_undefined($field, $val); + } + + my $ret = !$bad; + if ($remediate_imagemagick and !$bad) { + # return true if remediation succeeds + $ret = $self->repair_tiff_imagemagick($infile, $outfile); + + # repair the correct one when setting new headers + $infile = $outfile; + } + + while (my ($field, $val) = each(%$set_if_undefined_headers)) { + $self->set_new_if_undefined($field, $val); + } + + # Fix the XMP, if needed + if ($self->needs_xmp) { + # force required fields + $self->{newFields}{'XMP-tiff:BitsPerSample'} = 1; + $self->{newFields}{'XMP-tiff:Compression'} = 'T6/Group 4 Fax'; + $self->{newFields}{'XMP-tiff:Orientation'} = 'Horizontal (normal)'; + $self->{newFields}{'XMP-tiff:SamplesPerPixel'} = 1; + $self->{newFields}{'XMP-tiff:ResolutionUnit'} = 1; + $self->{newFields}{'XMP-tiff:ImageHeight'} = $self->{oldFields}{'IFD0:ImageHeight'}; + $self->{newFields}{'XMP-tiff:ImageWidth'} = $self->{oldFields}{'IFD0:ImageWidth'}; + $self->{newFields}{'XMP-tiff:PhotometricInterpretation'} = 'WhiteIsZero'; + + # copy other fields; use new value if it was provided + foreach my $field (qw(ResolutionUnit Artist XResolution YResolution Make Model)) { + if (defined $self->{oldFields}{"IFD0:$field"}) { + chomp($self->{oldFields}{"IFD0:$field"}); + $self->{newFields}{"IFD0:$field"} = $self->{oldFields}{"IFD0:$field"}; + } + + if (defined $self->{newFields}{"IFD0:$field"}) { + $self->{newFields}{"XMP-tiff:$field"} = $self->{newFields}{"IFD0:$field"}; + } } - # Fix the XMP, if needed - if ($self->needs_xmp) { - # force required fields - $self->{newFields}{'XMP-tiff:BitsPerSample'} = 1; - $self->{newFields}{'XMP-tiff:Compression'} = 'T6/Group 4 Fax'; - $self->{newFields}{'XMP-tiff:Orientation'} = 'Horizontal (normal)'; - $self->{newFields}{'XMP-tiff:SamplesPerPixel'} = 1; - $self->{newFields}{'XMP-tiff:ResolutionUnit'} = 1; - $self->{newFields}{'XMP-tiff:ImageHeight'} = $self->{oldFields}{'IFD0:ImageHeight'}; - $self->{newFields}{'XMP-tiff:ImageWidth'} = $self->{oldFields}{'IFD0:ImageWidth'}; - $self->{newFields}{'XMP-tiff:PhotometricInterpretation'} = 'WhiteIsZero'; - - # copy other fields; use new value if it was provided - foreach my $field (qw(ResolutionUnit Artist XResolution YResolution Make Model)) { - if (defined $self->{oldFields}{"IFD0:$field"}) { - chomp($self->{oldFields}{"IFD0:$field"}); - $self->{newFields}{"IFD0:$field"} = $self->{oldFields}{"IFD0:$field"}; - } - - if (defined $self->{newFields}{"IFD0:$field"}) { - $self->{newFields}{"XMP-tiff:$field"} = $self->{newFields}{"IFD0:$field"}; - } - } - - if (defined $self->{newFields}{"IFD0:DocumentName"}) { - $self->{newFields}{"XMP-dc:source"} = $self->{newFields}{"IFD0:DocumentName"}; - } else { - $self->{newFields}{"XMP-dc:source"} = $self->{oldFields}{"IFD0:DocumentName"}; - } + if (defined $self->{newFields}{"IFD0:DocumentName"}) { + $self->{newFields}{"XMP-dc:source"} = $self->{newFields}{"IFD0:DocumentName"}; + } else { + $self->{newFields}{"XMP-dc:source"} = $self->{oldFields}{"IFD0:DocumentName"}; } + } - $ret = $ret && $self->repair_tiff_exiftool( - $infile, - $outfile, - $self->{newFields} - ); + $ret = $ret && $self->repair_tiff_exiftool( + $infile, + $outfile, + $self->{newFields} + ); - my $labels = {format => 'tiff'}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); + my $labels = {format => 'tiff'}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); - return $ret; + return $ret; } sub is_rgb_tiff { - my $fields = shift; + my $fields = shift; - return ( - $fields->{'IFD0:SamplesPerPixel'} eq '3' and - $fields->{'IFD0:BitsPerSample'} eq '8 8 8' - ); + return ( + $fields->{'IFD0:SamplesPerPixel'} eq '3' and + $fields->{'IFD0:BitsPerSample'} eq '8 8 8' + ); } sub is_grayscale_tiff { - my $fields = shift; + my $fields = shift; - return ( - $fields->{'IFD0:SamplesPerPixel'} eq '1' and - $fields->{'IFD0:BitsPerSample'} eq '8' - ); + return ( + $fields->{'IFD0:SamplesPerPixel'} eq '1' and + $fields->{'IFD0:BitsPerSample'} eq '8' + ); } sub repair_tiff_exiftool { - my $self = shift; - my $infile = shift; - my $outfile = shift; - my $fields = shift; - - my $infile_size = -s $infile; - - # fix the DateTime - my $exifTool = new Image::ExifTool; - $exifTool->Options('ScanForXMP' => 1); - $exifTool->Options('IgnoreMinorErrors' => 1); - while (my ($field, $val) = each(%$fields)) { - my ($success, $errStr) = $exifTool->SetNewValue($field, $val); - if (defined $errStr) { - croak("Error setting new tag $field => $val: $errStr\n"); - return 0; - } + my $self = shift; + my $infile = shift; + my $outfile = shift; + my $fields = shift; + + my $infile_size = -s $infile; + + # fix the DateTime + my $exifTool = new Image::ExifTool; + $exifTool->Options('ScanForXMP' => 1); + $exifTool->Options('IgnoreMinorErrors' => 1); + while (my ($field, $val) = each(%$fields)) { + my ($success, $errStr) = $exifTool->SetNewValue($field, $val); + if (defined $errStr) { + croak("Error setting new tag $field => $val: $errStr\n"); + return 0; } + } - # make sure we have /something/ to write. All files should have - # Orientation=normal, so this won't break anything. - $exifTool->SetNewValue("Orientation", "normal"); + # make sure we have /something/ to write. All files should have + # Orientation=normal, so this won't break anything. + $exifTool->SetNewValue("Orientation", "normal"); - # whines if infile is same as outfile - my @file_params = ($infile); - push(@file_params, $outfile) if ($outfile ne $infile); + # whines if infile is same as outfile + my @file_params = ($infile); + push(@file_params, $outfile) if ($outfile ne $infile); - my $write_return = $exifTool->WriteInfo(@file_params); - if (!$write_return) { - croak( - "Couldn't remediate $infile: ". $exifTool->GetValue('Error') . "\n" - ); - return 0; - } + my $write_return = $exifTool->WriteInfo(@file_params); + if (!$write_return) { + croak( + "Couldn't remediate $infile: ". $exifTool->GetValue('Error') . "\n" + ); + return 0; + } - my $labels = {format => 'tiff'}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); + my $labels = {format => 'tiff'}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); - return $write_return; + return $write_return; } sub repair_tiff_imagemagick { - my $self = shift; - my $infile = shift; - my $outfile = shift; - - # try running IM on the TIFF file - get_logger()->trace( - "TIFF_REPAIR: attempting to repair $infile to $outfile\n" - ); - - my $in_exif = Image::ExifTool->new; - my $in_meta = $in_exif->ImageInfo($infile); - - # convert returns 0 on success, 1 on failure - my $compress_ok = HTFeed::Image::Magick::compress($infile, $outfile, '-compress' => 'Group4'); - my $labels = {format => 'tiff', tool => 'imagemagick'}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s $infile, $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); - croak("failed repairing $infile\n") unless $compress_ok; - - # Some metadata may be lost when imagemagick compresses infile to outfile. - # Here we are putting Artist back, or we'll crash at a later stage, - # due to missing ImageProducer (which depends on Artist). - my $out_exif = Image::ExifTool->new; - my $out_meta = $out_exif->ImageInfo($outfile); - if (defined $in_meta->{'Artist'} && !defined $out_meta->{'Artist'}) { - my ($success, $msg) = $out_exif->SetNewValue('Artist', $in_meta->{'Artist'}); - if (defined $msg) { - croak("Error setting new tag Artist => $in_meta->{'Artist'}: $msg\n"); - } else { - $self->update_tags($out_exif, $outfile); - } + my $self = shift; + my $infile = shift; + my $outfile = shift; + + # try running IM on the TIFF file + get_logger()->trace( + "TIFF_REPAIR: attempting to repair $infile to $outfile\n" + ); + + my $in_exif = Image::ExifTool->new; + my $in_meta = $in_exif->ImageInfo($infile); + + # convert returns 0 on success, 1 on failure + my $compress_ok = HTFeed::Image::Magick::compress($infile, $outfile, '-compress' => 'Group4'); + my $labels = {format => 'tiff', tool => 'imagemagick'}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s $infile, $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); + croak("failed repairing $infile\n") unless $compress_ok; + + # Some metadata may be lost when imagemagick compresses infile to outfile. + # Here we are putting Artist back, or we'll crash at a later stage, + # due to missing ImageProducer (which depends on Artist). + my $out_exif = Image::ExifTool->new; + my $out_meta = $out_exif->ImageInfo($outfile); + if (defined $in_meta->{'Artist'} && !defined $out_meta->{'Artist'}) { + my ($success, $msg) = $out_exif->SetNewValue('Artist', $in_meta->{'Artist'}); + if (defined $msg) { + croak("Error setting new tag Artist => $in_meta->{'Artist'}: $msg\n"); + } else { + $self->update_tags($out_exif, $outfile); } + } - $labels = {format => 'tiff', tool => 'exiftool'}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s $infile, $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); + $labels = {format => 'tiff', tool => 'exiftool'}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s $infile, $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); - return $compress_ok; + return $compress_ok; } sub _remediate_jpeg2000 { - my $self = shift; - my $infile = shift; - my $outfile = shift; - my $force_headers = shift || {}; - my $set_if_undefined_headers = shift; - - my $infile_size = -s $infile; - $self->{newFields} = $force_headers; - $self->{oldFields} = $self->get_exiftool_fields($infile); - get_logger()->trace("Remediating $infile to $outfile"); - - foreach my $field (qw(ImageWidth ImageHeight Compression)) { - $self->copy_old_to_new("Jpeg2000:$field", "XMP-tiff:$field"); - } - - foreach my $field (qw(Make Model)) { - $self->copy_old_to_new("IFD0:$field", "XMP-tiff:$field"); - } - - # handle old version of exiftool - if (not defined $self->{oldFields}->{'Jpeg2000:ColorSpace'}) { - $self->{oldFields}->{'Jpeg2000:ColorSpace'} = - $self->{oldFields}->{'Jpeg2000:Colorspace'}; - } - - # For IA, ColorSpace should always be sRGB. Only set these fields if they - # aren't already defined. - if (defined $self->{oldFields}->{'Jpeg2000:ColorSpace'} and $self->{oldFields}->{'Jpeg2000:ColorSpace'} eq 'sRGB') { - $self->{newFields}{'XMP-tiff:BitsPerSample'} = '8, 8, 8'; - $self->{newFields}{'XMP-tiff:PhotometricInterpretation'} = 'RGB'; - $self->{newFields}{'XMP-tiff:SamplesPerPixel'} = '3'; - } - - # Other package types may have grayscale JP2s that need remediation. - # Final image validation should kick these out if grayscale is not - # expected. - if (defined $self->{oldFields}->{'Jpeg2000:ColorSpace'} and $self->{oldFields}->{'Jpeg2000:ColorSpace'} eq 'Grayscale') { - $self->{newFields}{'XMP-tiff:BitsPerSample'} = '8'; - $self->{newFields}{'XMP-tiff:PhotometricInterpretation'} = 'BlackIsZero'; - $self->{newFields}{'XMP-tiff:SamplesPerPixel'} = '1'; - } - - # Orientation should always be normal - $self->set_new_if_undefined('XMP-tiff:Orientation', 'Horizontal (normal)'); - - # normalize the date to ISO8601 if it is close to that; assume UTC if no time zone given (rare in XMP) - my $normalized_date = fix_iso8601_date($self->{'oldFields'}{'XMP-tiff:DateTime'}); - $normalized_date = $set_if_undefined_headers->{'XMP-tiff:DateTime'} if not defined $normalized_date; - $self->{newFields}{'XMP-tiff:DateTime'} = $normalized_date; - - # try to get resolution from JPEG2000 headers - if (!$force_headers->{'Resolution'}) { - foreach my $prefix (qw(Jpeg2000:Capture Jpeg2000:Display IFD0:)) { - my $xres = $self->{oldFields}->{$prefix . 'XResolution'}; - my $yres = $self->{oldFields}->{$prefix . 'YResolution'}; - - next if not defined $xres and not defined $yres; - - if (($xres or $yres) and $xres != $yres) { - get_logger()->warn("Non-square pixels??! XRes $xres YRes $yres"); - } - - if ($xres) { - my $xresunit; - my $yresunit; - if ($prefix =~ /^Jpeg2000/) { - $xresunit = - $self->{oldFields}->{$prefix . 'XResolutionUnit'}; - $yresunit = - $self->{oldFields}->{$prefix . 'YResolutionUnit'}; - } else { - $xresunit = $self->{oldFields}->{$prefix . 'ResolutionUnit'}; - $yresunit = $xresunit; - } - - if (not $xresunit or not $yresunit or $xresunit ne $yresunit) { - get_logger()->warn("Resolution unit awry"); - } - - my $dpi_resolution = $self->_dpi($xres, $xresunit); - if (defined $dpi_resolution and $dpi_resolution >= 100) { - # Absurdly low DPI is likely to be an error or default, so don't - # use it and try to get it from somewhere else if it is < 100 - $force_headers->{Resolution} = $dpi_resolution; - } - } + my $self = shift; + my $infile = shift; + my $outfile = shift; + my $force_headers = shift || {}; + my $set_if_undefined_headers = shift; + + my $infile_size = -s $infile; + $self->{newFields} = $force_headers; + $self->{oldFields} = $self->get_exiftool_fields($infile); + get_logger()->trace("Remediating $infile to $outfile"); + + foreach my $field (qw(ImageWidth ImageHeight Compression)) { + $self->copy_old_to_new("Jpeg2000:$field", "XMP-tiff:$field"); + } + + foreach my $field (qw(Make Model)) { + $self->copy_old_to_new("IFD0:$field", "XMP-tiff:$field"); + } + + # handle old version of exiftool + if (not defined $self->{oldFields}->{'Jpeg2000:ColorSpace'}) { + $self->{oldFields}->{'Jpeg2000:ColorSpace'} = + $self->{oldFields}->{'Jpeg2000:Colorspace'}; + } + + # For IA, ColorSpace should always be sRGB. Only set these fields if they + # aren't already defined. + if (defined $self->{oldFields}->{'Jpeg2000:ColorSpace'} and $self->{oldFields}->{'Jpeg2000:ColorSpace'} eq 'sRGB') { + $self->{newFields}{'XMP-tiff:BitsPerSample'} = '8, 8, 8'; + $self->{newFields}{'XMP-tiff:PhotometricInterpretation'} = 'RGB'; + $self->{newFields}{'XMP-tiff:SamplesPerPixel'} = '3'; + } + + # Other package types may have grayscale JP2s that need remediation. + # Final image validation should kick these out if grayscale is not + # expected. + if (defined $self->{oldFields}->{'Jpeg2000:ColorSpace'} and $self->{oldFields}->{'Jpeg2000:ColorSpace'} eq 'Grayscale') { + $self->{newFields}{'XMP-tiff:BitsPerSample'} = '8'; + $self->{newFields}{'XMP-tiff:PhotometricInterpretation'} = 'BlackIsZero'; + $self->{newFields}{'XMP-tiff:SamplesPerPixel'} = '1'; + } + + # Orientation should always be normal + $self->set_new_if_undefined('XMP-tiff:Orientation', 'Horizontal (normal)'); + + # normalize the date to ISO8601 if it is close to that; assume UTC if no time zone given (rare in XMP) + my $normalized_date = fix_iso8601_date($self->{'oldFields'}{'XMP-tiff:DateTime'}); + $normalized_date = $set_if_undefined_headers->{'XMP-tiff:DateTime'} if not defined $normalized_date; + $self->{newFields}{'XMP-tiff:DateTime'} = $normalized_date; + + # try to get resolution from JPEG2000 headers + if (!$force_headers->{'Resolution'}) { + foreach my $prefix (qw(Jpeg2000:Capture Jpeg2000:Display IFD0:)) { + my $xres = $self->{oldFields}->{$prefix . 'XResolution'}; + my $yres = $self->{oldFields}->{$prefix . 'YResolution'}; + + next if not defined $xres and not defined $yres; + + if (($xres or $yres) and $xres != $yres) { + get_logger()->warn("Non-square pixels??! XRes $xres YRes $yres"); + } + + if ($xres) { + my $xresunit; + my $yresunit; + if ($prefix =~ /^Jpeg2000/) { + $xresunit = + $self->{oldFields}->{$prefix . 'XResolutionUnit'}; + $yresunit = + $self->{oldFields}->{$prefix . 'YResolutionUnit'}; + } else { + $xresunit = $self->{oldFields}->{$prefix . 'ResolutionUnit'}; + $yresunit = $xresunit; } - } - $self->_set_new_resolution($force_headers, $set_if_undefined_headers); - - # Add other provided new headers if requested and the file does not - # already have a value set for the given field - while (my ($field, $val) = each(%$set_if_undefined_headers)) { - $self->set_new_if_undefined($field, $val); - } - - # first copy old values, since XMP may be stripped/corrupted in some cases - my $exifTool = new Image::ExifTool; - $exifTool->Options('ScanForXMP' => 1); - $exifTool->Options('IgnoreMinorErrors' => 1); - my $info = $exifTool->SetNewValuesFromFile($infile, '*:*'); - while (my ($key, $val) = each(%$info)) { - if ($key eq 'Error') { - croak("Error extracting old headers... $key : $val. ($!)"); + if (not $xresunit or not $yresunit or $xresunit ne $yresunit) { + get_logger()->warn("Resolution unit awry"); } - } - # then copy new fields - while (my ($field, $val) = each(%{ $self->{newFields} })) { - $exifTool->SetNewValue($field); # first reset existing value, if any - my ($success, $errStr) = $exifTool->SetNewValue($field, $val); - if (defined $errStr) { - croak("Error setting new tag $field => $val: $errStr\n"); + my $dpi_resolution = $self->_dpi($xres, $xresunit); + if (defined $dpi_resolution and $dpi_resolution >= 100) { + # Absurdly low DPI is likely to be an error or default, so don't + # use it and try to get it from somewhere else if it is < 100 + $force_headers->{Resolution} = $dpi_resolution; } + } + } + } + + $self->_set_new_resolution($force_headers, $set_if_undefined_headers); + + # Add other provided new headers if requested and the file does not + # already have a value set for the given field + while (my ($field, $val) = each(%$set_if_undefined_headers)) { + $self->set_new_if_undefined($field, $val); + } + + # first copy old values, since XMP may be stripped/corrupted in some cases + my $exifTool = new Image::ExifTool; + $exifTool->Options('ScanForXMP' => 1); + $exifTool->Options('IgnoreMinorErrors' => 1); + my $info = $exifTool->SetNewValuesFromFile($infile, '*:*'); + while (my ($key, $val) = each(%$info)) { + if ($key eq 'Error') { + croak("Error extracting old headers... $key : $val. ($!)"); } + } + + # then copy new fields + while (my ($field, $val) = each(%{ $self->{newFields} })) { + $exifTool->SetNewValue($field); # first reset existing value, if any + my ($success, $errStr) = $exifTool->SetNewValue($field, $val); + if (defined $errStr) { + croak("Error setting new tag $field => $val: $errStr\n"); + } + } - my $ret_val = $self->update_tags($exifTool, $outfile, $infile); - my $labels = {format => 'jpeg2000'}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); + my $ret_val = $self->update_tags($exifTool, $outfile, $infile); + my $labels = {format => 'jpeg2000'}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); - return $ret_val; + return $ret_val; } sub _dpi { - my $self = shift; - my $xres = shift; - my $xresunit = shift; - - my $factor = undef; - - return unless $xres and $xresunit; - - # these read as: - # if ($xresunit eq 'um') { $factor = 25400; } ... etc - $xresunit eq 'um' and $factor = 25400; - $xresunit eq '0.01 mm' and $factor = 2540; - $xresunit eq '0.1 mm' and $factor = 254; - $xresunit eq 'mm' and $factor = 25.4; - $xresunit eq 'cm' and $factor = 2.54; - $xresunit eq 'm' and $factor = 0.0254; - $xresunit eq 'km' and $factor = 0.0000254; - $xresunit eq 'in' and $factor = 1; - $xresunit eq 'inches' and $factor = 1; - - if (defined $factor) { - return sprintf("%.0f", $xres * $factor); - } - - return; + my $self = shift; + my $xres = shift; + my $xresunit = shift; + + my $factor = undef; + + return unless $xres and $xresunit; + + # these read as: + # if ($xresunit eq 'um') { $factor = 25400; } ... etc + $xresunit eq 'um' and $factor = 25400; + $xresunit eq '0.01 mm' and $factor = 2540; + $xresunit eq '0.1 mm' and $factor = 254; + $xresunit eq 'mm' and $factor = 25.4; + $xresunit eq 'cm' and $factor = 2.54; + $xresunit eq 'm' and $factor = 0.0254; + $xresunit eq 'km' and $factor = 0.0000254; + $xresunit eq 'in' and $factor = 1; + $xresunit eq 'inches' and $factor = 1; + + if (defined $factor) { + return sprintf("%.0f", $xres * $factor); + } + + return; } sub _set_new_resolution { - my $self = shift; - my $force_headers = shift; - my $set_if_undefined_headers = shift; - - my $xmp_resolution = $self->_dpi( - $self->{oldFields}->{'XMP-tiff:XResolution'}, - $self->{oldFields}->{'XMP-tiff:ResolutionUnit'} - ); - - # if the resolution in the XMP is nonsense, ensure it gets updated with any - # info we might have even if we aren't otherwise forcing the resolution - my $force_res = ( - defined $force_headers->{'Resolution'} or - ( - defined $xmp_resolution and $xmp_resolution < 100 - ) - ); - - my $resolution = $force_headers->{'Resolution'} || $set_if_undefined_headers->{'Resolution'}; - - return unless defined $resolution; - - if ($force_res) { - $self->{newFields}->{'XMP-tiff:XResolution'} = $resolution; - $self->{newFields}->{'XMP-tiff:YResolution'} = $resolution; - $self->{newFields}->{'XMP-tiff:ResolutionUnit'} = 'inches'; - } else { - $self->set_new_if_undefined('XMP-tiff:XResolution', $resolution); - $self->set_new_if_undefined('XMP-tiff:YResolution', $resolution); - $self->set_new_if_undefined('XMP-tiff:ResolutionUnit', 'inches'); - } - - if (defined $self->{oldFields}->{'IFD0:XResolution'}) { - # Overwrite IFD0:XResolution/IFD0:YResolution if they are present - $self->{newFields}->{'IFD0:XResolution'} = $resolution; - $self->{newFields}->{'IFD0:YResolution'} = $resolution; - $self->{newFields}->{'IFD0:ResolutionUnit'} = 'inches'; - } + my $self = shift; + my $force_headers = shift; + my $set_if_undefined_headers = shift; + + my $xmp_resolution = $self->_dpi( + $self->{oldFields}->{'XMP-tiff:XResolution'}, + $self->{oldFields}->{'XMP-tiff:ResolutionUnit'} + ); + + # if the resolution in the XMP is nonsense, ensure it gets updated with any + # info we might have even if we aren't otherwise forcing the resolution + my $force_res = ( + defined $force_headers->{'Resolution'} or + ( + defined $xmp_resolution and $xmp_resolution < 100 + ) + ); + + my $resolution = $force_headers->{'Resolution'} || $set_if_undefined_headers->{'Resolution'}; + + return unless defined $resolution; + + if ($force_res) { + $self->{newFields}->{'XMP-tiff:XResolution'} = $resolution; + $self->{newFields}->{'XMP-tiff:YResolution'} = $resolution; + $self->{newFields}->{'XMP-tiff:ResolutionUnit'} = 'inches'; + } else { + $self->set_new_if_undefined('XMP-tiff:XResolution', $resolution); + $self->set_new_if_undefined('XMP-tiff:YResolution', $resolution); + $self->set_new_if_undefined('XMP-tiff:ResolutionUnit', 'inches'); + } + + if (defined $self->{oldFields}->{'IFD0:XResolution'}) { + # Overwrite IFD0:XResolution/IFD0:YResolution if they are present + $self->{newFields}->{'IFD0:XResolution'} = $resolution; + $self->{newFields}->{'IFD0:YResolution'} = $resolution; + $self->{newFields}->{'IFD0:ResolutionUnit'} = 'inches'; + } } sub prevalidate_field { - my $self = shift; - my $fieldname = shift; - # $expected can be a scalar or an array ref, if there are multiple permissible values - my $expected = shift; - my $remediable = shift; - - my $ok = 0; - my $actual = $self->{oldFields}{$fieldname}; - my $error_class = $remediable ? 'PREVALIDATE_REMEDIATE' : 'PREVALIDATE_ERR'; - - if (not defined $actual) { - $ok = 0; - } elsif (not defined $expected) { - # any value is OK - $ok = 1; - } elsif ( - (!ref($expected) and $actual eq $expected) - # OK value - or - (ref($expected) eq 'ARRAY' and (grep { $_ eq $actual } @$expected)) - ) { - $ok = 1; - } else { - # otherwise: unexpected/invalid value - $ok = 0; - } - - return $ok; + my $self = shift; + my $fieldname = shift; + # $expected can be a scalar or an array ref, if there are multiple permissible values + my $expected = shift; + my $remediable = shift; + + my $ok = 0; + my $actual = $self->{oldFields}{$fieldname}; + my $error_class = $remediable ? 'PREVALIDATE_REMEDIATE' : 'PREVALIDATE_ERR'; + + if (not defined $actual) { + $ok = 0; + } elsif (not defined $expected) { + # any value is OK + $ok = 1; + } elsif ( + (!ref($expected) and $actual eq $expected) + # OK value + or + (ref($expected) eq 'ARRAY' and (grep { $_ eq $actual } @$expected)) + ) { + $ok = 1; + } else { + # otherwise: unexpected/invalid value + $ok = 0; + } + + return $ok; } =item expand_lossless_jpeg2000() @@ -729,223 +729,223 @@ FILENAME.tif, and FILENAME.jp2 will be removed. =cut sub expand_lossless_jpeg2000 { - my $self = shift; - my $volume = shift; - my $path = shift; - my $files = shift; - - my $transformation_xp = XML::LibXML::XPathExpression->new( - "/jhove:jhove/jhove:repInfo/" . - "jhove:properties/jhove:property[jhove:name='JPEG2000Metadata']/jhove:values/" . - "jhove:property[jhove:name='Codestreams']/jhove:values/jhove:property[jhove:name='Codestream']/jhove:values/" . - "jhove:property[jhove:name='CodingStyleDefault']/jhove:values/" . - "jhove:property[jhove:name='Transformation']/jhove:values/jhove:value" - ); + my $self = shift; + my $volume = shift; + my $path = shift; + my $files = shift; + + my $transformation_xp = XML::LibXML::XPathExpression->new( + "/jhove:jhove/jhove:repInfo/" . + "jhove:properties/jhove:property[jhove:name='JPEG2000Metadata']/jhove:values/" . + "jhove:property[jhove:name='Codestreams']/jhove:values/jhove:property[jhove:name='Codestream']/jhove:values/" . + "jhove:property[jhove:name='CodingStyleDefault']/jhove:values/" . + "jhove:property[jhove:name='Transformation']/jhove:values/jhove:value" + ); + + $self->run_jhove( + $volume, + $path, + $files, + sub { + my $volume = shift; + my $file = shift; + my $node = shift; + + my $xpc = XML::LibXML::XPathContext->new($node); + register_namespaces($xpc); + my $transformation = $xpc->findvalue($transformation_xp); + + if (not defined $transformation) { + # malformed JPEG2000 image + $self->set_error( + "BadFile", + file => $file, + detail => "Can't find Transformation in JHOVE output" + ); + } elsif ($transformation eq '1') { + # lossless compression + my $jpeg2000 = $file; + my $jpeg2000_remediated = $file; + my $tiff = $file; + $tiff =~ s/\.jp2$/.tif/; + $jpeg2000_remediated =~ s/\.jp2$/.remediated.jp2/; + + my $labels = { + converted => "jpeg2000->tiff", + tool => 'grk_decompress' + }; + HTFeed::Image::Grok::decompress("$path/$jpeg2000", "$path/$tiff"); + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$path/$jpeg2000", $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s "$path/$tiff", $labels); + + # try to compress the TIFF -> JPEG2000 + get_logger()->trace("Compressing $path/$tiff to $path/$jpeg2000"); + + if (not defined $self->{recorded_image_compression}) { + $volume->record_premis_event('image_compression'); + $self->{recorded_image_compression} = 1; + } - $self->run_jhove( - $volume, - $path, - $files, - sub { - my $volume = shift; - my $file = shift; - my $node = shift; - - my $xpc = XML::LibXML::XPathContext->new($node); - register_namespaces($xpc); - my $transformation = $xpc->findvalue($transformation_xp); - - if (not defined $transformation) { - # malformed JPEG2000 image - $self->set_error( - "BadFile", - file => $file, - detail => "Can't find Transformation in JHOVE output" - ); - } elsif ($transformation eq '1') { - # lossless compression - my $jpeg2000 = $file; - my $jpeg2000_remediated = $file; - my $tiff = $file; - $tiff =~ s/\.jp2$/.tif/; - $jpeg2000_remediated =~ s/\.jp2$/.remediated.jp2/; - - my $labels = { - converted => "jpeg2000->tiff", - tool => 'grk_decompress' - }; - HTFeed::Image::Grok::decompress("$path/$jpeg2000", "$path/$tiff"); - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$path/$jpeg2000", $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s "$path/$tiff", $labels); - - # try to compress the TIFF -> JPEG2000 - get_logger()->trace("Compressing $path/$tiff to $path/$jpeg2000"); - - if (not defined $self->{recorded_image_compression}) { - $volume->record_premis_event('image_compression'); - $self->{recorded_image_compression} = 1; - } - - # Single quality level with reqested PSNR of 32dB. See DEV-10 - my $grk_compress_success = HTFeed::Image::Grok::compress( - "$path/$tiff", - "$path/$jpeg2000_remediated" - ); - if (!$grk_compress_success) { - $self->set_error( - "OperationFailed", - operation => "grk_compress", - file => "$path/$tiff", - detail => "grk_compress returned $?" - ); - } - $labels = { - converted => "tiff->jpeg2000", - tool => 'grk_decompress' - }; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$path/$tiff", $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s "$path/$jpeg2000_remediated", $labels); - - # copy all headers from the original jpeg2000 - # grk_compress loses info from IFD0 headers, which are sometimes present in JPEG2000 images - my $exiftool = new Image::ExifTool; - $exiftool->SetNewValuesFromFile("$path/$jpeg2000", '*:*'); - $exiftool->WriteInfo("$path/$jpeg2000_remediated"); - - $labels = {tool => 'exiftool'}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$path/$tiff", $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s "$path/$jpeg2000_remediated", $labels); - - # gotta do metrics first or we can't get file sizes - rename("$path/$jpeg2000_remediated", "$path/$jpeg2000"); - unlink("$path/$tiff"); - } - }, - "-m JPEG2000-hul" - ); + # Single quality level with reqested PSNR of 32dB. See DEV-10 + my $grk_compress_success = HTFeed::Image::Grok::compress( + "$path/$tiff", + "$path/$jpeg2000_remediated" + ); + if (!$grk_compress_success) { + $self->set_error( + "OperationFailed", + operation => "grk_compress", + file => "$path/$tiff", + detail => "grk_compress returned $?" + ); + } + $labels = { + converted => "tiff->jpeg2000", + tool => 'grk_decompress' + }; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$path/$tiff", $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s "$path/$jpeg2000_remediated", $labels); + + # copy all headers from the original jpeg2000 + # grk_compress loses info from IFD0 headers, which are sometimes present in JPEG2000 images + my $exiftool = new Image::ExifTool; + $exiftool->SetNewValuesFromFile("$path/$jpeg2000", '*:*'); + $exiftool->WriteInfo("$path/$jpeg2000_remediated"); + + $labels = {tool => 'exiftool'}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$path/$tiff", $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s "$path/$jpeg2000_remediated", $labels); + + # gotta do metrics first or we can't get file sizes + rename("$path/$jpeg2000_remediated", "$path/$jpeg2000"); + unlink("$path/$tiff"); + } + }, + "-m JPEG2000-hul" + ); } sub expand_other_file_formats { - my $self = shift; - my $volume = shift; - my $path = shift; - my $files = shift; - - my @other_recognized_formats = qw(.png .jpg); - my $imagemagick = get_config('imagemagick'); - my $imagemagick_cmd = qq($imagemagick); - - # Parse other recognized formats to .tif, put in same dir, then delete original. - foreach my $file (@$files) { - my $infile = "$path/$file"; - my @parts = fileparse($infile, @other_recognized_formats); - my $outname = $parts[0]; - my $ext = $parts[2]; - my $outfile = "$path/$outname.tif"; - - my $compress_ok = HTFeed::Image::Magick::compress( - $infile, - $outfile, - '-compress' => 'None' - ); + my $self = shift; + my $volume = shift; + my $path = shift; + my $files = shift; + + my @other_recognized_formats = qw(.png .jpg); + my $imagemagick = get_config('imagemagick'); + my $imagemagick_cmd = qq($imagemagick); + + # Parse other recognized formats to .tif, put in same dir, then delete original. + foreach my $file (@$files) { + my $infile = "$path/$file"; + my @parts = fileparse($infile, @other_recognized_formats); + my $outname = $parts[0]; + my $ext = $parts[2]; + my $outfile = "$path/$outname.tif"; + + my $compress_ok = HTFeed::Image::Magick::compress( + $infile, + $outfile, + '-compress' => 'None' + ); - if ($compress_ok) { - $self->copy_metadata($ext, $infile, $outfile); - my $infile_size = -s $infile; - unlink($infile); - my $labels = { - tool => 'imagemagick', - converted => $ext."->tiff" - }; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); - } else { - $self->set_error( - "OperationFailed", - operation => "imagemagick", - file => $infile, - detail => "decompress and ICC profile strip failed: returned $?" - ); - } + if ($compress_ok) { + $self->copy_metadata($ext, $infile, $outfile); + my $infile_size = -s $infile; + unlink($infile); + my $labels = { + tool => 'imagemagick', + converted => $ext."->tiff" + }; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", $infile_size, $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); + } else { + $self->set_error( + "OperationFailed", + operation => "imagemagick", + file => $infile, + detail => "decompress and ICC profile strip failed: returned $?" + ); } + } } sub copy_metadata { - my $self = shift; - my $ext = shift; - my $infile = shift; - my $outfile = shift; - - $self->{oldFields} = $self->get_exiftool_fields($infile); - $self->{newFields} = {}; - - # Delegate to the method that knows how to extract from a ".$ext" - if ($ext eq ".jpg") { - $self->{newFields} = extract_jpg_metadata($self->{oldFields}); - } elsif ($ext eq ".png") { - $self->{newFields} = extract_png_metadata($self->{oldFields}); - } else { - croak "copy_metadata knows not extension: $ext"; - return; - } - - # Write extracted metadata to outfile. - my $exifTool = new Image::ExifTool; - while (my ($field, $val) = each(%{$self->{newFields}})) { - my ($success, $errStr) = $exifTool->SetNewValue($field, $val); - if (defined $errStr) { - croak("Error setting new tag $field => $val: $errStr\n"); - return 0; - } - } - my $exif_write_status = $exifTool->WriteInfo($outfile); - unless ($exif_write_status == 1) { - get_logger()->trace("Failed EXIF write to $outfile"); + my $self = shift; + my $ext = shift; + my $infile = shift; + my $outfile = shift; + + $self->{oldFields} = $self->get_exiftool_fields($infile); + $self->{newFields} = {}; + + # Delegate to the method that knows how to extract from a ".$ext" + if ($ext eq ".jpg") { + $self->{newFields} = extract_jpg_metadata($self->{oldFields}); + } elsif ($ext eq ".png") { + $self->{newFields} = extract_png_metadata($self->{oldFields}); + } else { + croak "copy_metadata knows not extension: $ext"; + return; + } + + # Write extracted metadata to outfile. + my $exifTool = new Image::ExifTool; + while (my ($field, $val) = each(%{$self->{newFields}})) { + my ($success, $errStr) = $exifTool->SetNewValue($field, $val); + if (defined $errStr) { + croak("Error setting new tag $field => $val: $errStr\n"); + return 0; } + } + my $exif_write_status = $exifTool->WriteInfo($outfile); + unless ($exif_write_status == 1) { + get_logger()->trace("Failed EXIF write to $outfile"); + } } # Extract relevant jpg metadata sub extract_jpg_metadata { - my $olf = shift; # ref to $self->{oldFields}, a hash of exiftool data. - - # Return a hash of extracted metadata that we want to ensure - # is copied to the outfile. - my $h = { - 'IFD0:ResolutionUnit' => $olf->{'JFIF:ResolutionUnit'}, - 'IFD0:XResolution' => $olf->{'JFIF:XResolution'}, - 'IFD0:YResolution' => $olf->{'JFIF:YResolution'}, - 'XMP-tiff:ResolutionUnit' => $olf->{'JFIF:ResolutionUnit'}, - 'XMP-tiff:XResolution' => $olf->{'JFIF:XResolution'}, - 'XMP-tiff:YResolution' => $olf->{'JFIF:YResolution'} - }; - - return $h; + my $olf = shift; # ref to $self->{oldFields}, a hash of exiftool data. + + # Return a hash of extracted metadata that we want to ensure + # is copied to the outfile. + my $h = { + 'IFD0:ResolutionUnit' => $olf->{'JFIF:ResolutionUnit'}, + 'IFD0:XResolution' => $olf->{'JFIF:XResolution'}, + 'IFD0:YResolution' => $olf->{'JFIF:YResolution'}, + 'XMP-tiff:ResolutionUnit' => $olf->{'JFIF:ResolutionUnit'}, + 'XMP-tiff:XResolution' => $olf->{'JFIF:XResolution'}, + 'XMP-tiff:YResolution' => $olf->{'JFIF:YResolution'} + }; + + return $h; } sub extract_png_metadata { - my $olf = shift; # ref to $self->{oldFields}, a hash of exiftool data. - - my $originalPixelUnit = $olf->{'PNG-pHYs:PixelUnits'}; - my $pixelUnit = "in"; - my $multiplier = 1; - - # PNG might give resolution in meters, we want it in centimeters. - # 100 pixels-per-meter is 1 pixels-per-centimeter (100:1) - if ($originalPixelUnit eq "meters") { - $pixelUnit = "cm"; - $multiplier = 0.01; - } - - my $h = { - 'IFD0:ResolutionUnit' => $pixelUnit, - 'IFD0:XResolution' => $olf->{'PNG-pHYs:PixelsPerUnitX'} * $multiplier, - 'IFD0:YResolution' => $olf->{'PNG-pHYs:PixelsPerUnitY'} * $multiplier, - 'XMP-tiff:ResolutionUnit' => $pixelUnit, - 'XMP-tiff:XResolution' => $olf->{'PNG-pHYs:PixelsPerUnitX'} * $multiplier, - 'XMP-tiff:YResolution' => $olf->{'PNG-pHYs:PixelsPerUnitY'} * $multiplier - }; - - return $h; + my $olf = shift; # ref to $self->{oldFields}, a hash of exiftool data. + + my $originalPixelUnit = $olf->{'PNG-pHYs:PixelUnits'}; + my $pixelUnit = "in"; + my $multiplier = 1; + + # PNG might give resolution in meters, we want it in centimeters. + # 100 pixels-per-meter is 1 pixels-per-centimeter (100:1) + if ($originalPixelUnit eq "meters") { + $pixelUnit = "cm"; + $multiplier = 0.01; + } + + my $h = { + 'IFD0:ResolutionUnit' => $pixelUnit, + 'IFD0:XResolution' => $olf->{'PNG-pHYs:PixelsPerUnitX'} * $multiplier, + 'IFD0:YResolution' => $olf->{'PNG-pHYs:PixelsPerUnitY'} * $multiplier, + 'XMP-tiff:ResolutionUnit' => $pixelUnit, + 'XMP-tiff:XResolution' => $olf->{'PNG-pHYs:PixelsPerUnitX'} * $multiplier, + 'XMP-tiff:YResolution' => $olf->{'PNG-pHYs:PixelsPerUnitY'} * $multiplier + }; + + return $h; } =item remediate_tiffs() @@ -963,367 +963,367 @@ for remediate_image (qv) =cut sub remediate_tiffs { - my $self = shift; - my $volume = shift; - my $tiffpath = shift; - my $files = shift; - my $headers_sub = shift; - - my $repStatus_xp = XML::LibXML::XPathExpression->new( - '/jhove:jhove/jhove:repInfo/jhove:status' - ); - my $error_xp = XML::LibXML::XPathExpression->new( - '/jhove:jhove/jhove:repInfo/jhove:messages/jhove:message[@severity="error"]' - ); - - my $stage_path = $volume->get_staging_directory(); - my $objid = $volume->get_objid(); - - # check if Artist and/or ModifyDate header is full of binary junk; if so remove it - foreach my $tiff (@$files) { - my $headers = $self->get_exiftool_fields("$tiffpath/$tiff"); - my $needwrite = 0; - my $exiftool = new Image::ExifTool; - - $exiftool->Options('ScanForXMP' => 1); - $exiftool->Options('IgnoreMinorErrors' => 1); - foreach my $field ('IFD0:ModifyDate', 'IFD0:Artist') { - my $header = $headers->{$field}; - eval { - # see if the header is valid ascii or UTF-8 - my $decoded_header = decode('utf-8', $header, Encode::FB_CROAK); - }; - if ($@) { - # if not, strip it - $exiftool->SetNewValue($field); - $needwrite = 1; - - } - } - if ($needwrite) { - $exiftool->WriteInfo("$tiffpath/$tiff"); - } + my $self = shift; + my $volume = shift; + my $tiffpath = shift; + my $files = shift; + my $headers_sub = shift; + + my $repStatus_xp = XML::LibXML::XPathExpression->new( + '/jhove:jhove/jhove:repInfo/jhove:status' + ); + my $error_xp = XML::LibXML::XPathExpression->new( + '/jhove:jhove/jhove:repInfo/jhove:messages/jhove:message[@severity="error"]' + ); + + my $stage_path = $volume->get_staging_directory(); + my $objid = $volume->get_objid(); + + # check if Artist and/or ModifyDate header is full of binary junk; if so remove it + foreach my $tiff (@$files) { + my $headers = $self->get_exiftool_fields("$tiffpath/$tiff"); + my $needwrite = 0; + my $exiftool = new Image::ExifTool; + + $exiftool->Options('ScanForXMP' => 1); + $exiftool->Options('IgnoreMinorErrors' => 1); + foreach my $field ('IFD0:ModifyDate', 'IFD0:Artist') { + my $header = $headers->{$field}; + eval { + # see if the header is valid ascii or UTF-8 + my $decoded_header = decode('utf-8', $header, Encode::FB_CROAK); + }; + if ($@) { + # if not, strip it + $exiftool->SetNewValue($field); + $needwrite = 1; + + } } - - $self->run_jhove( - $volume, - $tiffpath, - $files, - sub { - my ($volume, $file, $node) = @_; - my $xpc = XML::LibXML::XPathContext->new($node); - my $force_headers = undef; - my $set_if_undefined_headers = undef; - my $renamed_file = undef; - register_namespaces($xpc); - - $self->{jhoveStatus} = $xpc->findvalue($repStatus_xp); - $self->{jhoveErrors} = [ - map { $_->textContent } $xpc->findnodes($error_xp) - ]; - - # get headers that may depend on the individual file - if ($headers_sub) { - ($force_headers, $set_if_undefined_headers, $renamed_file) = &$headers_sub($file); - } - - my $outfile = "$stage_path/$file"; - $outfile = "$stage_path/$renamed_file" if (defined $renamed_file); - - $self->remediate_image( - "$tiffpath/$file", - $outfile, - $force_headers, - $set_if_undefined_headers - ); - }, - "-m TIFF-hul" - ); - - my $labels = {format => "tiff", tool => 'jhove'}; - $self->{job_metrics}->inc("ingest_imageremediate_items_total", $labels); + if ($needwrite) { + $exiftool->WriteInfo("$tiffpath/$tiff"); + } + } + + $self->run_jhove( + $volume, + $tiffpath, + $files, + sub { + my ($volume, $file, $node) = @_; + my $xpc = XML::LibXML::XPathContext->new($node); + my $force_headers = undef; + my $set_if_undefined_headers = undef; + my $renamed_file = undef; + register_namespaces($xpc); + + $self->{jhoveStatus} = $xpc->findvalue($repStatus_xp); + $self->{jhoveErrors} = [ + map { $_->textContent } $xpc->findnodes($error_xp) + ]; + + # get headers that may depend on the individual file + if ($headers_sub) { + ($force_headers, $set_if_undefined_headers, $renamed_file) = &$headers_sub($file); + } + + my $outfile = "$stage_path/$file"; + $outfile = "$stage_path/$renamed_file" if (defined $renamed_file); + + $self->remediate_image( + "$tiffpath/$file", + $outfile, + $force_headers, + $set_if_undefined_headers + ); + }, + "-m TIFF-hul" + ); + + my $labels = {format => "tiff", tool => 'jhove'}; + $self->{job_metrics}->inc("ingest_imageremediate_items_total", $labels); } sub convert_tiff_to_jpeg2000 { - my $self = shift; - my $seq = shift; - - my $volume = $self->{volume}; - my $preingest_dir = $volume->get_preingest_directory(); - my $infile = "$preingest_dir/$seq.tif"; - my $outfile = "$preingest_dir/$seq.jp2"; - my ($field, $val); - - # From Roger: - # $levels would be derived from the largest dimension; minimum is 5: - # - 0 < x <= 6400 : nlev=5 - # - 6400 < x <= 12800 : nlev=6 - # - 12800 < x <= 25600 : nlev=7 - my $maxdim = max( - $self->{oldFields}->{'IFD0:ImageWidth'}, - $self->{oldFields}->{'IFD0:ImageHeight'} - ); - my $levels = max(5, ceil(log($maxdim / 100) / log(2)) - 1); - - # try to compress the TIFF -> JPEG2000 - get_logger()->trace("Compressing $infile to $outfile"); - - if (not defined $self->{recorded_image_compression}) { - $volume->record_premis_event('image_compression'); - $self->{recorded_image_compression} = 1; - } - - # Settings for grk_compress recommended from Roger Espinosa. "-slope" - # is a VBR compression mode; the value of 42988 corresponds to pre-6.4 - # slope of 51180, the current (as of 5/6/2011) recommended setting for - # Google digifeeds. - # - # 43300 corresponds to the old recommendation of 51492 for general material. - - # save some info from the TIFF - foreach my $tag (qw(Artist Make Model)) { - my $tagvalue = $self->{oldFields}->{"IFD0:$tag"}; - $tagvalue = $self->{oldFields}->{"XMP-tiff:$tag"} if not defined $tagvalue; - $self->{newFields}->{"XMP-tiff:$tag"} = $tagvalue if defined $tagvalue; - } - - # first decompress & strip ICC profiles - my $imagemagick = get_config('imagemagick'); - my $imagemagick_cmd = qq($imagemagick); - - # Make sure it's 24-bit RGB or 8-bit grayscale and keep it that way. - # Breaking out some expressions to make this condition easier to read. - my $sample_per_px = $self->{oldFields}->{'IFD0:SamplesPerPixel'}; - my $bits_per_sample = $self->{oldFields}->{'IFD0:BitsPerSample'}; - - # Figure out args for imagemagick: - my %magick_args = ('-compress' => 'None'); - if ($sample_per_px eq '3' and ($bits_per_sample eq '8' or $sample_per_px eq '8 8 8')) { - $magick_args{'-type'} = 'TrueColor'; - } elsif ($bits_per_sample eq '8' and $sample_per_px eq '1') { - $magick_args{'-type'} = 'Grayscale'; - $magick_args{'-depth'} = '8'; - } - - my $magick_compress_success = HTFeed::Image::Magick::compress( - $infile, - "$infile.unc.tif", - %magick_args - ); - - my $labels = {converted => "tiff->jpeg2000", tool => "imagemagick"}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s $infile, $labels); - $self->{job_metrics}->add( - "ingest_imageremediate_bytes_w_total", - -s "$infile.unc.tif", - $labels + my $self = shift; + my $seq = shift; + + my $volume = $self->{volume}; + my $preingest_dir = $volume->get_preingest_directory(); + my $infile = "$preingest_dir/$seq.tif"; + my $outfile = "$preingest_dir/$seq.jp2"; + my ($field, $val); + + # From Roger: + # $levels would be derived from the largest dimension; minimum is 5: + # - 0 < x <= 6400 : nlev=5 + # - 6400 < x <= 12800 : nlev=6 + # - 12800 < x <= 25600 : nlev=7 + my $maxdim = max( + $self->{oldFields}->{'IFD0:ImageWidth'}, + $self->{oldFields}->{'IFD0:ImageHeight'} + ); + my $levels = max(5, ceil(log($maxdim / 100) / log(2)) - 1); + + # try to compress the TIFF -> JPEG2000 + get_logger()->trace("Compressing $infile to $outfile"); + + if (not defined $self->{recorded_image_compression}) { + $volume->record_premis_event('image_compression'); + $self->{recorded_image_compression} = 1; + } + + # Settings for grk_compress recommended from Roger Espinosa. "-slope" + # is a VBR compression mode; the value of 42988 corresponds to pre-6.4 + # slope of 51180, the current (as of 5/6/2011) recommended setting for + # Google digifeeds. + # + # 43300 corresponds to the old recommendation of 51492 for general material. + + # save some info from the TIFF + foreach my $tag (qw(Artist Make Model)) { + my $tagvalue = $self->{oldFields}->{"IFD0:$tag"}; + $tagvalue = $self->{oldFields}->{"XMP-tiff:$tag"} if not defined $tagvalue; + $self->{newFields}->{"XMP-tiff:$tag"} = $tagvalue if defined $tagvalue; + } + + # first decompress & strip ICC profiles + my $imagemagick = get_config('imagemagick'); + my $imagemagick_cmd = qq($imagemagick); + + # Make sure it's 24-bit RGB or 8-bit grayscale and keep it that way. + # Breaking out some expressions to make this condition easier to read. + my $sample_per_px = $self->{oldFields}->{'IFD0:SamplesPerPixel'}; + my $bits_per_sample = $self->{oldFields}->{'IFD0:BitsPerSample'}; + + # Figure out args for imagemagick: + my %magick_args = ('-compress' => 'None'); + if ($sample_per_px eq '3' and ($bits_per_sample eq '8' or $sample_per_px eq '8 8 8')) { + $magick_args{'-type'} = 'TrueColor'; + } elsif ($bits_per_sample eq '8' and $sample_per_px eq '1') { + $magick_args{'-type'} = 'Grayscale'; + $magick_args{'-depth'} = '8'; + } + + my $magick_compress_success = HTFeed::Image::Magick::compress( + $infile, + "$infile.unc.tif", + %magick_args + ); + + my $labels = {converted => "tiff->jpeg2000", tool => "imagemagick"}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s $infile, $labels); + $self->{job_metrics}->add( + "ingest_imageremediate_bytes_w_total", + -s "$infile.unc.tif", + $labels + ); + + if (!$magick_compress_success) { + $self->set_error( + "OperationFailed", + operation => "imagemagick", + file => $infile, + detail => "decompress and ICC profile strip failed: returned $?" ); - - if (!$magick_compress_success) { - $self->set_error( - "OperationFailed", - operation => "imagemagick", - file => $infile, - detail => "decompress and ICC profile strip failed: returned $?" - ); - } - - # strip off the XMP to prevent confusion during conversion - my $exifTool = new Image::ExifTool; - $exifTool->Options('ScanForXMP' => 1); - $exifTool->Options('IgnoreMinorErrors' => 1); - $exifTool->SetNewValue('XMP', undef, Protected => 1); - $self->update_tags($exifTool, "$infile.unc.tif"); - - my $grk_compress_success = HTFeed::Image::Grok::compress( - "$infile.unc.tif", - "$outfile", - -n => $levels + } + + # strip off the XMP to prevent confusion during conversion + my $exifTool = new Image::ExifTool; + $exifTool->Options('ScanForXMP' => 1); + $exifTool->Options('IgnoreMinorErrors' => 1); + $exifTool->SetNewValue('XMP', undef, Protected => 1); + $self->update_tags($exifTool, "$infile.unc.tif"); + + my $grk_compress_success = HTFeed::Image::Grok::compress( + "$infile.unc.tif", + "$outfile", + -n => $levels + ); + + if (!$grk_compress_success) { + $self->set_error( + "OperationFailed", + operation => "grk_compress", + file => $infile, + detail => "grk_compress returned $?" ); - - if (!$grk_compress_success) { - $self->set_error( - "OperationFailed", - operation => "grk_compress", - file => $infile, - detail => "grk_compress returned $?" - ); + } + + $labels = {converted => "tiff->jpeg2000", tool => "grk_compress"}; + $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$infile.unc.tif", $labels); + $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); + # then set new metadata fields - the rest will automatically be + # set from the JP2 + foreach $field (qw(XResolution YResolution ResolutionUnit Artist Make Model)) { + $self->copy_old_to_new("IFD0:$field", "XMP-tiff:$field"); + } + + # Don't worry about setting all fields here, since it will also be run through + # the JPEG2000 remediation. + $self->copy_old_to_new("IFD0:ModifyDate", "XMP-tiff:DateTime"); + $self->set_new_if_undefined("XMP-tiff:Compression", "JPEG 2000"); + $self->set_new_if_undefined("XMP-tiff:Orientation", "normal"); + + $exifTool = new Image::ExifTool; + $exifTool->Options('ScanForXMP' => 1); + $exifTool->Options('IgnoreMinorErrors' => 1); + while (($field, $val) = each(%{ $self->{newFields} })) { + my ($success, $errStr) = $exifTool->SetNewValue($field, $val); + if (defined $errStr) { + croak("Error setting new tag $field => $val: $errStr\n"); } + } - $labels = {converted => "tiff->jpeg2000", tool => "grk_compress"}; - $self->{job_metrics}->add("ingest_imageremediate_bytes_r_total", -s "$infile.unc.tif", $labels); - $self->{job_metrics}->add("ingest_imageremediate_bytes_w_total", -s $outfile, $labels); - # then set new metadata fields - the rest will automatically be - # set from the JP2 - foreach $field (qw(XResolution YResolution ResolutionUnit Artist Make Model)) { - $self->copy_old_to_new("IFD0:$field", "XMP-tiff:$field"); - } - - # Don't worry about setting all fields here, since it will also be run through - # the JPEG2000 remediation. - $self->copy_old_to_new("IFD0:ModifyDate", "XMP-tiff:DateTime"); - $self->set_new_if_undefined("XMP-tiff:Compression", "JPEG 2000"); - $self->set_new_if_undefined("XMP-tiff:Orientation", "normal"); - - $exifTool = new Image::ExifTool; - $exifTool->Options('ScanForXMP' => 1); - $exifTool->Options('IgnoreMinorErrors' => 1); - while (($field, $val) = each(%{ $self->{newFields} })) { - my ($success, $errStr) = $exifTool->SetNewValue($field, $val); - if (defined $errStr) { - croak("Error setting new tag $field => $val: $errStr\n"); - } - } - - $self->update_tags($exifTool, "$outfile"); + $self->update_tags($exifTool, "$outfile"); } # normalize the date to ISO8601 if it is close to that; assume UTC if no time zone given (rare in XMP) sub fix_iso8601_date { - my $datetime = shift; - - if (defined $datetime and $datetime =~ /^(\d{4})[:\/-](\d\d)[:\/-](\d\d)[T ](\d\d):(\d\d)(:\d\d)?(Z|[+-]\d{2}:\d{2})?$/) { - my ($Y, $M, $D, $h, $m, $s, $tz) = ($1, $2, $3, $4, $5, $6, $7); - $s = ':00' if not defined $s; - $tz = 'Z' if not defined $tz; - return "$Y-$M-${D}T$h:$m$s$tz"; - } else { - # missing or very badly formatted date - return; - } + my $datetime = shift; + + if (defined $datetime and $datetime =~ /^(\d{4})[:\/-](\d\d)[:\/-](\d\d)[T ](\d\d):(\d\d)(:\d\d)?(Z|[+-]\d{2}:\d{2})?$/) { + my ($Y, $M, $D, $h, $m, $s, $tz) = ($1, $2, $3, $4, $5, $6, $7); + $s = ':00' if not defined $s; + $tz = 'Z' if not defined $tz; + return "$Y-$M-${D}T$h:$m$s$tz"; + } else { + # missing or very badly formatted date + return; + } } # normalize to TIFF 6.0 spec "YYYY:MM:DD HH:MM:SS" sub fix_tiff_date { - my $datetime = shift; - - return if not defined $datetime; - - if ($datetime =~ /^(\d{4}).(\d{2}).(\d{2}).(\d{2}).(\d{2}).(\d{2})/) { - return "$1:$2:$3 $4:$5:$6"; - } elsif ($datetime =~ /^(\d{4}).?(\d{2}).?(\d{2})/) { - return "$1:$2:$3 00:00:00"; - } - # two digit year from 1990s; assume mm/dd/yy - elsif ($datetime =~ /^(\d{2})\/(\d{2})\/(9\d)$/) { - return "19$3:$1:$2 00:00:00"; - } - # four digit year, no time; assume mm/dd/yy - elsif ($datetime =~ qr(^(\d{2})[/:-](\d{2})[/:-](\d{4})$)) { - return "$3:$1:$2 00:00:00"; - } else { - # garbage / unparseable - return; - } + my $datetime = shift; + + return if not defined $datetime; + + if ($datetime =~ /^(\d{4}).(\d{2}).(\d{2}).(\d{2}).(\d{2}).(\d{2})/) { + return "$1:$2:$3 $4:$5:$6"; + } elsif ($datetime =~ /^(\d{4}).?(\d{2}).?(\d{2})/) { + return "$1:$2:$3 00:00:00"; + } + # two digit year from 1990s; assume mm/dd/yy + elsif ($datetime =~ /^(\d{2})\/(\d{2})\/(9\d)$/) { + return "19$3:$1:$2 00:00:00"; + } + # four digit year, no time; assume mm/dd/yy + elsif ($datetime =~ qr(^(\d{2})[/:-](\d{2})[/:-](\d{4})$)) { + return "$3:$1:$2 00:00:00"; + } else { + # garbage / unparseable + return; + } } # update with remediated dates without regard to whether they are null or not sub set_new_date_fields { - my $self = shift; - my $new_tiffdate = shift; - my $new_xmpdate = shift; - - my $tiffdate = Date::Manip::Date->new; - $tiffdate->parse($new_tiffdate); - my $xmpdate = Date::Manip::Date->new; - $xmpdate->parse($new_xmpdate); - - $self->{newFields}{'IFD0:ModifyDate'} = $tiffdate->printf("%Y:%m:%d %H:%M:%S"); - if ($self->needs_xmp) { - $self->{newFields}{'XMP-tiff:DateTime'} = $xmpdate->printf("%O"); - } + my $self = shift; + my $new_tiffdate = shift; + my $new_xmpdate = shift; + + my $tiffdate = Date::Manip::Date->new; + $tiffdate->parse($new_tiffdate); + my $xmpdate = Date::Manip::Date->new; + $xmpdate->parse($new_xmpdate); + + $self->{newFields}{'IFD0:ModifyDate'} = $tiffdate->printf("%Y:%m:%d %H:%M:%S"); + if ($self->needs_xmp) { + $self->{newFields}{'XMP-tiff:DateTime'} = $xmpdate->printf("%O"); + } } sub fix_datetime { - my $self = shift; - my $default_datetime = shift; + my $self = shift; + my $default_datetime = shift; - my $tiff_datetime = fix_tiff_date($self->{oldFields}{'IFD0:ModifyDate'}); - my $xmp_datetime = fix_iso8601_date($self->{oldFields}{'XMP-tiff:DateTime'}); + my $tiff_datetime = fix_tiff_date($self->{oldFields}{'IFD0:ModifyDate'}); + my $xmp_datetime = fix_iso8601_date($self->{oldFields}{'XMP-tiff:DateTime'}); - $self->set_new_date_fields($tiff_datetime, $xmp_datetime); - $self->fix_datetime_missing($tiff_datetime, $xmp_datetime, $default_datetime); + $self->set_new_date_fields($tiff_datetime, $xmp_datetime); + $self->fix_datetime_missing($tiff_datetime, $xmp_datetime, $default_datetime); - # fix_datetime_missing may have updated these - $self->fix_datetime_mismatch( - $self->{newFields}{'IFD0:ModifyDate'}, - $self->{newFields}{'XMP-tiff:DateTime'}, - $default_datetime - ); + # fix_datetime_missing may have updated these + $self->fix_datetime_mismatch( + $self->{newFields}{'IFD0:ModifyDate'}, + $self->{newFields}{'XMP-tiff:DateTime'}, + $default_datetime + ); } sub fix_datetime_missing { - my $self = shift; - my $tiff_datetime = shift; - my $xmp_datetime = shift; - my $default_datetime = shift; - - # copy TIFF DateTime if we have it and need the XMP - if (defined $tiff_datetime and $self->needs_xmp and not defined $xmp_datetime) { - $self->set_new_date_fields($tiff_datetime, $tiff_datetime); - } - # copy XMP DateTime if we have it and need the TIFF DateTime - elsif (defined $xmp_datetime and not defined $tiff_datetime) { - $self->set_new_date_fields($xmp_datetime, $xmp_datetime); - } - # if we have neither, set both (set_new_date_fields will only set the - # XMP if needed) - elsif (not defined $xmp_datetime and not defined $tiff_datetime) { - $self->set_new_date_fields($default_datetime, $default_datetime); - } + my $self = shift; + my $tiff_datetime = shift; + my $xmp_datetime = shift; + my $default_datetime = shift; + + # copy TIFF DateTime if we have it and need the XMP + if (defined $tiff_datetime and $self->needs_xmp and not defined $xmp_datetime) { + $self->set_new_date_fields($tiff_datetime, $tiff_datetime); + } + # copy XMP DateTime if we have it and need the TIFF DateTime + elsif (defined $xmp_datetime and not defined $tiff_datetime) { + $self->set_new_date_fields($xmp_datetime, $xmp_datetime); + } + # if we have neither, set both (set_new_date_fields will only set the + # XMP if needed) + elsif (not defined $xmp_datetime and not defined $tiff_datetime) { + $self->set_new_date_fields($default_datetime, $default_datetime); + } } sub fix_datetime_mismatch { - my $self = shift; - my $tiff_datetime = shift; - my $xmp_datetime = shift; - my $default_datetime = shift; + my $self = shift; + my $tiff_datetime = shift; + my $xmp_datetime = shift; + my $default_datetime = shift; - # if there is no XMP, we don't need to make sure they match - return unless $self->needs_xmp; + # if there is no XMP, we don't need to make sure they match + return unless $self->needs_xmp; - if ($self->tiff_xmp_date_mismatch($tiff_datetime, $xmp_datetime)) { - $self->set_new_date_fields($default_datetime, $default_datetime); - } + if ($self->tiff_xmp_date_mismatch($tiff_datetime, $xmp_datetime)) { + $self->set_new_date_fields($default_datetime, $default_datetime); + } } sub tiff_xmp_date_mismatch { - my $self = shift; - my $tiff_datetime = shift; - my $xmp_datetime = shift; - - my $mix_datetime = undef; - - if ( - defined $tiff_datetime and - # accept tiff-style or ISO8601 style - $tiff_datetime =~ /^(\d{4}).(\d{2}).(\d{2}).(\d{2}).(\d{2}).(\d{2})([+-]\d{2}:\d{2})?$/ - ) { - $mix_datetime = "\1-\2-\3T\4:\5:\6"; - } else { - # shouldn't happen at this point - tiff_datetime should either be null or - # well formatted - $self->set_error( - "BadValue", - field => 'IFD0:ModifyDate', - actual => $tiff_datetime, - detail => 'Expected format YYYY:MM:DD HH:mm:ss' - ); - return undef; - } + my $self = shift; + my $tiff_datetime = shift; + my $xmp_datetime = shift; + + my $mix_datetime = undef; + + if ( + defined $tiff_datetime and + # accept tiff-style or ISO8601 style + $tiff_datetime =~ /^(\d{4}).(\d{2}).(\d{2}).(\d{2}).(\d{2}).(\d{2})([+-]\d{2}:\d{2})?$/ + ) { + $mix_datetime = "\1-\2-\3T\4:\5:\6"; + } else { + # shouldn't happen at this point - tiff_datetime should either be null or + # well formatted + $self->set_error( + "BadValue", + field => 'IFD0:ModifyDate', + actual => $tiff_datetime, + detail => 'Expected format YYYY:MM:DD HH:mm:ss' + ); + return undef; + } - return ( - defined $xmp_datetime and - defined $mix_datetime and - $xmp_datetime !~ /^\Q$mix_datetime\E([+-]\d{2}:\d{2})?/ - ) + return ( + defined $xmp_datetime and + defined $mix_datetime and + $xmp_datetime !~ /^\Q$mix_datetime\E([+-]\d{2}:\d{2})?/ + ) } sub needs_xmp { - my $self = shift; + my $self = shift; - return (grep { $_ =~ /^XMP-/ } keys(%{$self->{oldFields}})) + return (grep { $_ =~ /^XMP-/ } keys(%{$self->{oldFields}})) } 1; @@ -1392,7 +1392,7 @@ $self->copy_old_to_new($oldFieldName, $newFieldName); =item set_new_if_undefined() Copies old field value to the new field value, but only if the old value is defined -and the new one isn't. +and the new one is either missing or empty. $self->set_new_if_undefined($newFieldName,$newFieldVal); From 6b1f1167e10fb0a4c820f272500723d84f973774 Mon Sep 17 00:00:00 2001 From: Aaron Elkiss Date: Thu, 18 Sep 2025 15:10:22 -0400 Subject: [PATCH 3/3] ETT-571: handle empty DateTime in RGB TIFFs "copy_old_to_new" was checking that the field existed previously, but not that it was non-empty. This doesn't cover every possible scenario, and there are likely other cases of invalid metadata in RGB TIFFs that we may need to handle in the future, but we can deal with it when it comes up. --- lib/HTFeed/Stage/ImageRemediate.pm | 5 +++-- t/fixtures/simple/test/README.md | 19 ++++++++++++++++++ t/fixtures/simple/test/empty_datetime.zip | Bin 0 -> 9835 bytes .../simple/test/rgb_tif_empty_datetime.zip | Bin 0 -> 58450 bytes t/local_ingest.t | 18 +++++++++++++++++ 5 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 t/fixtures/simple/test/README.md create mode 100644 t/fixtures/simple/test/empty_datetime.zip create mode 100644 t/fixtures/simple/test/rgb_tif_empty_datetime.zip diff --git a/lib/HTFeed/Stage/ImageRemediate.pm b/lib/HTFeed/Stage/ImageRemediate.pm index 8082e5c9..8a503ea5 100644 --- a/lib/HTFeed/Stage/ImageRemediate.pm +++ b/lib/HTFeed/Stage/ImageRemediate.pm @@ -171,8 +171,9 @@ sub copy_old_to_new($$$) { my $oldValue = $self->{oldFields}->{$oldFieldName}; if ( - defined $self->{oldFields}->{$oldFieldName} and - not defined $self->{newFields}->{$newFieldName} + defined $self->{oldFields}->{$oldFieldName} + and $self->{oldFields}->{$oldFieldName} ne '' + and not defined $self->{newFields}->{$newFieldName} ) { $self->{newFields}->{$newFieldName} = $oldValue; } diff --git a/t/fixtures/simple/test/README.md b/t/fixtures/simple/test/README.md new file mode 100644 index 00000000..3f3cc61a --- /dev/null +++ b/t/fixtures/simple/test/README.md @@ -0,0 +1,19 @@ +These are various hand-generated test fixtures demonstrating different +scenarios for validating and normalizing images. + +For various scenarios involving invalid metadata, it may be difficult to get +ExifTool or other tools to be willing to add the invalid metadata. + +An example of adding metadata in its raw format with ExifTool using perl: + +```perl +use Image::ExifTool; + +my $e = Image::ExifTool->new(); +$e->SetNewValue("IFD0:ModifyDate" => "", Type => 'Raw'); +$e->WriteInfo("00000001.tif"); +``` + +The same method could be used to write invalid or nonsensical resolution +information or units, etc; the key is the `Type => 'Raw'` to avoid ExifTool's +built-in normalization and validation. diff --git a/t/fixtures/simple/test/empty_datetime.zip b/t/fixtures/simple/test/empty_datetime.zip new file mode 100644 index 0000000000000000000000000000000000000000..7ed37c4a40da395528ee6e76e71aa4adbbb65af3 GIT binary patch literal 9835 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 H1oXcElKxiL literal 0 HcmV?d00001 diff --git a/t/fixtures/simple/test/rgb_tif_empty_datetime.zip b/t/fixtures/simple/test/rgb_tif_empty_datetime.zip new file mode 100644 index 0000000000000000000000000000000000000000..bf85e7a59ec196e25109dec31220465fdbc9989e GIT binary patch literal 58450 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%0DJRLgt>DrRk|JSCqfyY9#stJ81oK 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 LlSP@ee_H sub { $validate->run(); ok($validate->succeeded()); }; + + it "remediates empty DateTime in a bitonal TIFF file" => sub { + my $volume = unpacked_volume("empty_datetime"); + HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); + HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); + my $validate = HTFeed::VolumeValidator->new(volume => $volume); + $validate->run(); + ok($validate->succeeded()); + }; + + it "remediates empty DateTime in an RGB TIFF file" => sub { + my $volume = unpacked_volume("rgb_tif_empty_datetime"); + HTFeed::PackageType::Simple::ImageRemediate->new(volume => $volume)->run(); + HTFeed::PackageType::Simple::SourceMETS->new(volume => $volume)->run(); + my $validate = HTFeed::VolumeValidator->new(volume => $volume); + $validate->run(); + ok($validate->succeeded()); + }; }; };