diff --git a/app/logical/apng_inspector.rb b/app/logical/apng_inspector.rb deleted file mode 100644 index 2ff1d54de..000000000 --- a/app/logical/apng_inspector.rb +++ /dev/null @@ -1,113 +0,0 @@ -# Parse an PNG and return whether or not it's animated. -class APNGInspector - attr_reader :frames - - PNG_MAGIC_NUMBER = ["89504E470D0A1A0A"].pack('H*') - - def initialize(file_path) - @file_path = file_path - @corrupted = false - @animated = false - end - - # PNG file consists of 8-byte magic number, followed by arbitrary number of chunks - # Each chunk has the following structure: - # 4-byte length (unsigned int, can be zero) - # 4-byte name (ASCII string consisting of letters A-z) - # (length)-byte data - # 4-byte CRC - # - # Any data after chunk named IEND is irrelevant - # APNG frame count is inside a chunk named acTL, in first 4 bytes of data. - - # This function calls associated block for each PNG chunk - # parameters passed are |chunk_name, chunk_length, file_descriptor| - # returns true if file is read succesfully from start to IEND, - # or if 100 000 chunks are read; returns false otherwise. - def each_chunk - iend_reached = false - File.open(@file_path, 'rb') do |file| - # check if file is not PNG at all - return false if file.read(8) != PNG_MAGIC_NUMBER - - chunks = 0 - - # We could be dealing with large number of chunks, - # so the code should be optimized to create as few objects as possible. - # All literal strings are frozen and read() function uses string buffer. - chunkheader = '' - while file.read(8, chunkheader) - # ensure that first 8 bytes from chunk were read properly - if chunkheader.nil? || chunkheader.length < 8 - return false - end - - current_pos = file.tell - - chunk_len, chunk_name = chunkheader.unpack("Na4".freeze) - return false if chunk_name =~ /[^A-Za-z]/ - yield chunk_name, chunk_len, file - - # no need to read further if IEND is reached - if chunk_name == "IEND".freeze - iend_reached = true - break - end - - # check if we processed too many chunks already - # if we did, file is probably maliciously formed - # fail gracefully without marking the file as corrupt - chunks += 1 - if chunks > 100_000 - iend_reached = true - break - end - - # jump to the next chunk - go forward by chunk length + 4 bytes CRC - file.seek(current_pos + chunk_len + 4, IO::SEEK_SET) - end - end - - iend_reached - end - - def inspect! - actl_corrupted = false - - read_success = each_chunk do |name, len, file| - if name == 'acTL'.freeze - framecount = parse_actl(len, file) - if framecount < 1 - actl_corrupted = true - else - @animated = true - @frames = framecount - end - end - end - - @corrupted = !read_success || actl_corrupted - self - end - - def corrupted? - @corrupted - end - - def animated? - !@corrupted && @animated - end - - private - - # return number of frames in acTL or -1 on failure - def parse_actl(len, file) - return -1 if len != 8 - framedata = file.read(4) - if framedata.nil? || framedata.length != 4 - return -1 - end - - framedata.unpack1("N".freeze) - end -end diff --git a/app/logical/media_file/image.rb b/app/logical/media_file/image.rb index 21a772f2b..1ed6a5516 100644 --- a/app/logical/media_file/image.rb +++ b/app/logical/media_file/image.rb @@ -80,7 +80,7 @@ class MediaFile::Image < MediaFile end def is_animated_png? - file_ext == :png && APNGInspector.new(file.path).inspect!.animated? + file_ext == :png && metadata.fetch("PNG:AnimationFrames", 1) > 1 end # @return [Vips::Image] the Vips image object for the file diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb index 391d37566..7e5b9951d 100644 --- a/config/initializers/zeitwerk.rb +++ b/config/initializers/zeitwerk.rb @@ -1,6 +1,5 @@ Rails.autoloaders.each do |autoloader| autoloader.inflector.inflect( - "apng_inspector" => "APNGInspector", "sftp" => "SFTP" ) end diff --git a/test/unit/apng_inspector_test.rb b/test/unit/apng_inspector_test.rb deleted file mode 100644 index 1f69a9d43..000000000 --- a/test/unit/apng_inspector_test.rb +++ /dev/null @@ -1,63 +0,0 @@ -require "test_helper" - -class APNGInspectorTest < ActiveSupport::TestCase - def inspect(filename) - apng = APNGInspector.new("#{Rails.root}/test/files/apng/#{filename}") - apng.inspect! - apng - end - context "APNG inspector" do - should "correctly parse normal APNG file" do - apng = inspect('normal_apng.png') - assert_equal(3, apng.frames) - assert_equal(true, apng.animated?) - assert_equal(false, apng.corrupted?) - end - - should "recognize 1-frame APNG as animated" do - apng = inspect('single_frame.png') - assert_equal(1, apng.frames) - assert_equal(true, apng.animated?) - assert_equal(false, apng.corrupted?) - end - - should "correctly parse normal PNG file" do - apng = inspect('not_apng.png') - assert_equal(false, apng.animated?) - assert_equal(false, apng.corrupted?) - end - - should "handle empty file" do - apng = inspect('empty.png') - assert_equal(false, apng.animated?) - assert_equal(true, apng.corrupted?) - end - - should "handle corrupted files" do - apng = inspect('iend_missing.png') - assert_equal(false, apng.animated?) - assert_equal(true, apng.corrupted?) - apng = inspect('misaligned_chunks.png') - assert_equal(false, apng.animated?) - assert_equal(true, apng.corrupted?) - apng = inspect('broken.png') - assert_equal(false, apng.animated?) - assert_equal(true, apng.corrupted?) - end - - should "handle incorrect acTL chunk" do - apng = inspect('actl_wronglen.png') - assert_equal(false, apng.animated?) - assert_equal(true, apng.corrupted?) - apng = inspect('actl_zero_frames.png') - assert_equal(false, apng.animated?) - assert_equal(true, apng.corrupted?) - end - - should "handle non-png files" do - apng = inspect('jpg.png') - assert_equal(false, apng.animated?) - assert_equal(true, apng.corrupted?) - end - end -end diff --git a/test/unit/media_file_test.rb b/test/unit/media_file_test.rb index 3d6fd4b26..021df68cb 100644 --- a/test/unit/media_file_test.rb +++ b/test/unit/media_file_test.rb @@ -190,6 +190,59 @@ class MediaFileTest < ActiveSupport::TestCase end end + context "a PNG file" do + context "that is not animated" do + should "not be detected as animated" do + file = MediaFile.open("test/files/apng/not_apng.png") + + assert_equal(false, file.is_corrupt?) + assert_equal(false, file.is_animated?) + end + end + + context "that is animated" do + should "be detected as animated" do + file = MediaFile.open("test/files/apng/normal_apng.png") + + assert_equal(false, file.is_corrupt?) + assert_equal(true, file.is_animated?) + end + end + + context "that is animated but with only one frame" do + should "not be detected as animated" do + file = MediaFile.open("test/files/apng/single_frame.png") + + assert_equal(false, file.is_corrupt?) + assert_equal(false, file.is_animated?) + end + end + + context "that is animated but malformed" do + should "be handled correctly" do + file = MediaFile.open("test/files/apng/iend_missing.png") + assert_equal(false, file.is_corrupt?) + assert_equal(true, file.is_animated?) + + file = MediaFile.open("test/files/apng/misaligned_chunks.png") + assert_equal(true, file.is_corrupt?) + assert_equal(true, file.is_animated?) + + file = MediaFile.open("test/files/apng/broken.png") + assert_equal(true, file.is_corrupt?) + assert_equal(true, file.is_animated?) + + file = MediaFile.open("test/files/apng/actl_wronglen.png") + assert_equal(false, file.is_corrupt?) + assert_equal(true, file.is_animated?) + + file = MediaFile.open("test/files/apng/actl_zero_frames.png") + assert_equal(false, file.is_corrupt?) + assert_equal(false, file.is_animated?) + end + end + end + context "a corrupt GIF" do should "still read the metadata" do @file = MediaFile.open("test/files/test-corrupt.gif")