From 2c0617ce21a16717c0db46fec057dc45ee1ef066 Mon Sep 17 00:00:00 2001 From: Yuki Morohoshi Date: Mon, 21 Nov 2016 20:03:54 +0900 Subject: [PATCH] support apng --- lib/chunky_png.rb | 7 + lib/chunky_png/animation.rb | 181 ++++++++++++++++++ lib/chunky_png/animation_datastream.rb | 125 ++++++++++++ lib/chunky_png/chunk.rb | 110 +++++++++++ lib/chunky_png/frame.rb | 144 ++++++++++++++ spec/chunky_png/animation_datastream_spec.rb | 74 +++++++ spec/chunky_png/animation_spec.rb | 102 ++++++++++ spec/chunky_png/frame_spec.rb | 74 +++++++ spec/resources/2x2_loop_animation.png | Bin 0 -> 403 bytes ...2_loop_animation_without_default_image.png | Bin 0 -> 285 bytes 10 files changed, 817 insertions(+) create mode 100644 lib/chunky_png/animation.rb create mode 100644 lib/chunky_png/animation_datastream.rb create mode 100644 lib/chunky_png/frame.rb create mode 100644 spec/chunky_png/animation_datastream_spec.rb create mode 100644 spec/chunky_png/animation_spec.rb create mode 100644 spec/chunky_png/frame_spec.rb create mode 100644 spec/resources/2x2_loop_animation.png create mode 100644 spec/resources/2x2_loop_animation_without_default_image.png diff --git a/lib/chunky_png.rb b/lib/chunky_png.rb index 404ec205..5fd8e4d0 100644 --- a/lib/chunky_png.rb +++ b/lib/chunky_png.rb @@ -20,6 +20,10 @@ # {ChunkyPNG::Dimension}:: geometry helper class representing a dimension (i.e. width x height). # {ChunkyPNG::Vector}:: geometry helper class representing a series of points. # +# {ChunkyPNG::Animation:: class to represent Animated PNG (APNG) images. +# {ChunkyPNG::Frame:: class to represent each frames that construct the animation. +# {ChunkyPNG::AnimationDatastream:: represents the internal structure of an APNG {ChunkyPNG::Animation} +# # @author Willem van Bergen module ChunkyPNG @@ -163,3 +167,6 @@ def self.force_binary(str) # Canvas / Image classes require 'chunky_png/canvas' require 'chunky_png/image' + +# APNG +require 'chunky_png/animation' diff --git a/lib/chunky_png/animation.rb b/lib/chunky_png/animation.rb new file mode 100644 index 00000000..d593d37e --- /dev/null +++ b/lib/chunky_png/animation.rb @@ -0,0 +1,181 @@ +require 'chunky_png/frame' +require 'chunky_png/animation_datastream' + +module ChunkyPNG + class Animation < Canvas + + # @return [Array] The array of frames in this animation + attr_accessor :frames + + # @return [Boolean] Indicates whether the content of IDAT chunk (content of + # {#pixels @pixels}) is the first frame for this animation + attr_accessor :default_image_is_first_frame + + # @return [Integer] Indicates the number of times that this animation should + # play + attr_accessor :num_plays + + ################################################################# + # CONSTANTS + ################################################################# + + # Indicates that the APNG image play in infinite loop. + INFINITE_LOOP = 0 + + # Indicates that no disposal is done on the frame before rendering the next. + APNG_DISPOSE_OP_NONE = 0 + + # Indicates that the frame's region of the output buffer is to be cleared to + # fully transparent black before rendering the next frame. + APNG_DISPOSE_OP_BACKGROUND = 1 + + # Indicates that the frame's region of the output buffer is to be reverted + # to the previous contents before rendering the next frame. + APNG_DISPOSE_OP_PREVIOUS = 2 + + # Indicates that all color components of the frame, including alpha, + # overwrite the current contents of the frame's output buffer region. + APNG_BLEND_OP_SOURCE = 0 + + # Indicates that the frame should be composited onto the output buffer based + # on its alpha, using a simple OVER operation. + APNG_BLEND_OP_OVER = 1 + + ################################################################# + # CONSTRUCTORS + ################################################################# + + # Initializes a new Animation instance. + # + # @param [Integer] width The width in pixels of this canvas + # @param [Integer] height The height in pixels of this canvas + # @param [Integer, Array, ...] initial The initial background or + # the initial pixel values. (see also: {ChunkyPNG::Canvas#initialize}) + # @param [Boolean] first_frame if it is true, width, + # height and initial also used to generate the first + # frame. + # + # @see ChunkyPNG::Canvas#initialize + def initialize(width, height, initial = ChunkyPNG::Color::TRANSPARENT, first_frame = false) + super(width, height, initial) + @default_image_is_first_frame = first_frame + @frames = first_frame ? [ChunkyPNG::Frame.new(width, height, initial)] : [] + end + + # Initializes a new Animation instance by {ChunkyPNG::Frame} instance. + # @param [ChunkyPNG::Frame] frame The first frame for this animation. + def self.from_frame(frame) + new(frame.width, frame.height, frame.pixels).tap do |animation| + animation.default_image_is_first_frame = true + animation.frames = [frame] + end + end + + # Returns the total number of frames in this animation. + # @return [Integer] The total numer of frames. + def num_frames + @frames.size + end + + ################################################################# + # DECODING + ################################################################# + + class << self + # Decodes an Animation from an Animated PNG encoded string. + # @param [String] str The string to read from. + # @return [ChunkyPNG::Animation] The animation decoded from the Animated + # PNG encoded string. + def from_blob(str) + from_datastream(ChunkyPNG::AnimationDatastream.from_blob(str)) + end + + alias_method :from_string, :from_blob + + # Decodes an Animation from an Animated PNG encoded file. + # @param [String] filename The file to read from. + # @return [ChunkyPNG::Animation] The animation decoded from the Animated + # PNG file. + def from_file(filename) + from_datastream(ChunkyPNG::AnimationDatastream.from_file(filename)) + end + + # Decodes an Animation from an Animated PNG encoded stream. + # @param [IO, #read] io The stream to read from. + # @return [ChunkyPNG::Animation] The animation decoded from the Animated + # PNG stream. + def from_io(io) + from_datastream(ChunkyPNG::AnimationDatastream.from_io(io)) + end + + # Decodes the Animation from an Animated PNG datastream instance. + # @param [ChunkyPNG::AnimationDatastream] ads The datastream to decode. + # @return [ChunkyPNG::Animation] The animation decoded from the Animated + # PNG datastream. + def from_datastream(ads) + animation = super(ads) + ads.animation_control_chunk ||= ChunkyPNG::Chunk::AnimationControl.new + + animation.default_image_is_first_frame = ads.default_image_is_first_frame? + animation.num_plays = ads.animation_control_chunk.num_plays + + ads.frame_control_chunks.each do |fctl_chunk| + fdat_chunks = ads.slice_frame_data_chunks(fctl_chunk) + frame = ChunkyPNG::Frame.from_chunks(fctl_chunk, fdat_chunks, ads) + animation.frames << frame + end + + unless ads.animation_control_chunk.num_frames == animation.num_frames + raise ChunkyPNG::ExpectationFailed, 'num_frames missmatched!' + end + animation + end + end + + ################################################################# + # ENCODING + ################################################################# + + # Converts this Animation to a datastream, so that it can be saved as an + # Animated PNG image. + # @param [Hash, Symbol] constraints The constraints to use when encoding the + # animation. + # @return [ChunkyPNG::AnimationDatastream] The Animated PNG datastream. + # @see ChunkyPNG::Canvas::PNGEncoding#to_datastream + def to_datastream(constraints = {}) + encoding = determine_png_encoding(constraints) + + ds = AnimationDatastream.new + ds.header_chunk = Chunk::Header.new(:width => width, :height => height, + :color => encoding[:color_mode], + :depth => encoding[:bit_depth], + :interlace => encoding[:interlace]) + if encoding[:color_mode] == ChunkyPNG::COLOR_INDEXED + ds.palette_chunk = encoding_palette.to_plte_chunk + ds.transparency_chunk = encoding_palette.to_trns_chunk unless encoding_palette.opaque? + end + + ds.animation_control_chunk = Chunk::AnimationControl.new(:num_frames => num_frames, + :num_plays => num_plays) + + data = encode_png_pixelstream(encoding[:color_mode], encoding[:bit_depth], + encoding[:interlace], encoding[:filtering]) + ds.data_chunks = Chunk::ImageData.split_in_chunks(data, encoding[:compression]) + + idx = 0 + frames.each do |frame| + if idx == 0 && @default_image_is_first_frame + ds.frame_control_chunks << frame.to_frame_control_chunk(0) + else + fctl_chunk, *fdat_chunks = *frame.to_chunks(idx, constraints) + ds.frame_control_chunks << fctl_chunk + ds.frame_data_chunks = ds.frame_data_chunks + fdat_chunks + end + idx = ds.animation_chunks.size + end + + ds.end_chunk = Chunk::End.new + return ds + end + end +end diff --git a/lib/chunky_png/animation_datastream.rb b/lib/chunky_png/animation_datastream.rb new file mode 100644 index 00000000..ca3ed167 --- /dev/null +++ b/lib/chunky_png/animation_datastream.rb @@ -0,0 +1,125 @@ +module ChunkyPNG + class AnimationDatastream < Datastream + # The chunk containing the information of the animation. + # @return [ChunkyPNG::Chunk::AnimationControl] + attr_accessor :animation_control_chunk + + # All fcTL chunks in this Animated PNG file. + # @return [Array] + attr_accessor :frame_control_chunks + + # All fdAT chunks in this Animated PNG file. + # @return [Array] + attr_accessor :frame_data_chunks + + class << self + # Reads an Animated PNG datastream from an input stream + # @param [IO] io The stream to read from. + # @return [ChunkyPNG::AnimationDatastream] The loaded datastream instance. + def from_io(io) + ads = super + ads.other_chunks.each do |chunk| + case chunk + when ChunkyPNG::Chunk::AnimationControl; ads.animation_control_chunk = chunk + when ChunkyPNG::Chunk::FrameData; ads.frame_data_chunks << chunk + when ChunkyPNG::Chunk::FrameControl; ads.frame_control_chunks << chunk + end + end + ads.other_chunks = ads.other_chunks - ([ads.animation_control_chunk] + + ads.frame_control_chunks + + ads.frame_data_chunks) + return ads + end + end + + # Initializes a new AnimationDatastream instance. + def initialize + super + @frame_control_chunks = [] + @frame_data_chunks = [] + end + + # Enumerates the chunks in this datastream. + # @see ChunkyPNG::Datastream#each_chunk + def each_chunk + yield(header_chunk) + other_chunks.each { |chunk| yield(chunk) } + yield(palette_chunk) if palette_chunk + yield(transparency_chunk) if transparency_chunk + yield(physical_chunk) if physical_chunk + sorted_data_chunks.each { |chunk| yield(chunk) } + yield(end_chunk) + end + + # Returns an array of acTL/IDAT/fcTL/fdAT chunks in the order they should + # appear in the PNG file. + # @return [Array] array of acTL/IDAT/fcTL/fdAT chunks + def sorted_data_chunks + res = [@animation_control_chunk] + first_fctl = @frame_control_chunks.sort_by(&:sequence_number).first + res << first_fctl if default_image_is_first_frame? + res << @data_chunks + res << sorted_animation_chunks - (res.include?(first_fctl) ? [first_fctl] : []) + res.flatten.compact + end + + # Returns an array of fcTL/fdAT chunks. + # @return [Array] array of fcTL/fdAT chunks + def animation_chunks + @frame_control_chunks + @frame_data_chunks + end + + # Returns an array of fcTL/fdAT chunks in order of sequence number. + # @return [Array] array of fcTL/fdAT chunks + def sorted_animation_chunks + animation_chunks.sort_by(&:sequence_number) + end + + # Returns whether default image (contents of IDAT chunk) is first frame + # data. + # @return [Boolean] + def default_image_is_first_frame? + return false if @frame_control_chunks.empty? + # When default image is first frame, chunk structure will be as below: + # + # IHDR + # acTL + # fcTL (sequence number: 1) + # IDAT (default image) + # fcTL (sequence number: 2) + # fdAT (sequence number: 3) + # ... + # IEND + # + # if default image is *not* first frame, chunk structure will be as below: + # + # IHDR + # acTL + # IDAT (default image) + # fcTL (sequence number: 1) + # fdAT (sequence number: 2) + # fcTL (sequence number: 3) + # ... + # IEND + # + # in this case, difference of first fcTL chunk's sequence number to second + # one is bigger than 1. + first_fctl, second_fctl = @frame_control_chunks.sort_by(&:sequence_number)[0..1] + (second_fctl.sequence_number - first_fctl.sequence_number) == 1 + end + + # Returns all fdAT chunks to which the argument (fcTL chunk) is applied. + # @return [Array] array of fdAT chunks + def slice_frame_data_chunks(fctl_chunk) + min_seq_num = fctl_chunk.sequence_number + res = [] + sorted_animation_chunks.each do |c| + if c.sequence_number > min_seq_num + break if c.is_a?(Chunk::FrameControl) + res << c + end + end + res + end + end +end diff --git a/lib/chunky_png/chunk.rb b/lib/chunky_png/chunk.rb index 2fb0e1c3..ecab071c 100644 --- a/lib/chunky_png/chunk.rb +++ b/lib/chunky_png/chunk.rb @@ -377,6 +377,113 @@ class InternationalText < Generic # TODO end + # The animation control (acTL) chunk contains information about the animated + # image, and must appear before the first IDAT chunk. + # + # https://wiki.mozilla.org/APNG_Specification#.60acTL.60:_The_Animation_Control_Chunk + class AnimationControl < Base + # The attribute to indicate the total number of frames. + # @return [Integer] + attr_accessor :num_frames + + # The attribute to indicate the number of times that this animation should + # play. If it is 0, the animation should play indefinitely. + # @return [Integer] + attr_accessor :num_plays + + def initialize(attrs = {}) + super('acTL', attrs) + @num_frames ||= 0 + @num_plays ||= Animation::INFINITE_LOOP + end + + def self.read(type, content) + fields = content.unpack('NN') + new(:num_frames => fields[0], + :num_plays => fields[1]) + end + + def content + [num_frames, num_plays].pack('NN') + end + end + + # The frame control (fcTL) chunk contains information about the frame in + # animation. It must appear before the IDAT or fdAT chunks of the frame to + # which it applies. It has a 4 byte sequence number which shared with fdAT + # chunks. + # + # https://wiki.mozilla.org/APNG_Specification#.60fcTL.60:_The_Frame_Control_Chunk + class FrameControl < Base + attr_accessor :sequence_number, :width, :height, + :x_offset, :y_offset, :delay_num, :delay_den, + :dispose_op, :blend_op + + def initialize(attrs = {}) + super('fcTL', attrs) + @sequence_number ||= 0 + @width ||= 1 + @height ||= 1 + @x_offset ||= 0 + @y_offset ||= 0 + @delay_num ||= 1 + @delay_den ||= 1 + @dispose_op ||= Animation::APNG_DISPOSE_OP_NONE + @blend_op ||= Animation::APNG_BLEND_OP_SOURCE + end + + def self.read(type, content) + fields = content.unpack('N5nnCC') + new(:sequence_number => fields[0], + :width => fields[1], + :height => fields[2], + :x_offset => fields[3], + :y_offset => fields[4], + :delay_num => fields[5], + :delay_den => fields[6], + :dispose_op => fields[7], + :blend_op => fields[8]) + end + + def content + [sequence_number, width, height, + x_offset, y_offset, delay_num, delay_den, + dispose_op, blend_op].pack('N5nnCC') + end + end + + class FrameData < Base + attr_accessor :sequence_number, :frame_data + + # The frame data (fdAT) chunk contains the atual image data, and has a 4 + # byte sequence number which shared with fcTL chunks. + # It has the same structure as an `IDAT` chunk, except preceded by a + # sequence number. + # + # https://wiki.mozilla.org/APNG_Specification#.60fdAT.60:_The_Frame_Data_Chunk + def initialize(attrs = {}) + super('fdAT', attrs) + @sequence_number ||= 0 + end + + def self.read(type, content) + new(:sequence_number => content[0..3].unpack('N').first, + :frame_data => content[4..-1]) + end + + def self.combine_chunks(frame_data_chunks) + zstream = Zlib::Inflate.new + frame_data_chunks.each { |c| zstream << c.frame_data } + inflated = zstream.finish + zstream.close + inflated + end + + def content + [[sequence_number].pack('N'), frame_data].join + end + end + # Maps chunk types to classes, based on the four byte chunk type indicator # at the beginning of a chunk. # @@ -394,6 +501,9 @@ class InternationalText < Generic 'zTXt' => CompressedText, 'iTXt' => InternationalText, 'pHYs' => Physical, + 'acTL' => AnimationControl, + 'fcTL' => FrameControl, + 'fdAT' => FrameData, } end end diff --git a/lib/chunky_png/frame.rb b/lib/chunky_png/frame.rb new file mode 100644 index 00000000..2af7f2c0 --- /dev/null +++ b/lib/chunky_png/frame.rb @@ -0,0 +1,144 @@ +module ChunkyPNG + class Frame < Canvas + # @return [Integer] The sequence number of this frame + attr_accessor :sequence_number + + # @return [Integer] The number of columns in this frame + attr_accessor :width + + # @return [Integer] The number of rows in this frame + attr_accessor :height + + # @return [Integer] X position at which to render this frame + attr_accessor :x_offset + + # @return [Integer] Y position at which to render this frame + attr_accessor :y_offset + + # @return [Integer] Frame delay fraction numerator + attr_accessor :delay_num + + # @return [Integer] Frame delay fraction denominator + attr_accessor :delay_den + + # @return [Integer] Type of frame area disposal to be done after rendering + # this frame + attr_accessor :dispose_op + + # @return [Integer] Type of frame area rendering for this frame + attr_accessor :blend_op + + # Initializes a new Frame instance by using a canvas instance. + # @param [ChunkyPNG::Canvas] canvas The canvas to convert to frame. + # @return [ChunkyPNG::Frame] The newly constructed frame instance. + def self.from_canvas(canvas, attrs = {}) + new(canvas.width, canvas.height, canvas.pixels.dup, attrs) + end + + # Decodes a Frame from a PNG encoded file. + # @param [String] filename The file to read from. + # @return [ChunkyPNG::Frame] The frame decoded from the PNG file. + def self.from_file(file, attrs = {}) + from_canvas(ChunkyPNG::Canvas.from_file(file), attrs) + end + + # Decodes the Frame from a PNG datastream instance. + # @param [ChunkyPNG::Datastream] ds The datastream to decode. + # @return [ChunkyPNG::Frame] The frame decoded from the PNG datastream. + def self.from_datastream(ds, attrs = {}) + from_canvas(super(ds), attrs) + end + + # Builds a Frame instance from a fcTL chunk and fdAT chunk from a PNG + # datastream. + # @param fctl_chunk [ChunkyPNG::Chunk::FrameControl] + # @param fdat_chunks [Array] + # @return [ChunkyPNG::Frame] The loaded Frame instance. + def self.from_chunks(fctl_chunk, fdat_chunks, ads) + color_mode = ads.header_chunk.color + depth = ads.header_chunk.depth + interlace = ads.header_chunk.interlace + decoding_palette, transparent_color = nil, nil + + if fdat_chunks.any? + case color_mode + when ChunkyPNG::COLOR_INDEXED + decoding_palette = ChunkyPNG::Palette.from_chunks(ads.palette_chunk, + ads.transparency_chunk) + when ChunkyPNG::COLOR_TRUECOLOR + transparent_color = ads.transparency_chunk.truecolor_entry(depth) if ads.transparency_chunk + when ChunkyPNG::COLOR_GRAYSCALE + transparent_color = ads.transparency_chunk.grayscale_entry(depth) if ads.transparency_chunk + end + + imagedata = Chunk::FrameData.combine_chunks(fdat_chunks) + frame = decode_png_pixelstream(imagedata, fctl_chunk.width, fctl_chunk.height, + color_mode, depth, interlace, + decoding_palette, transparent_color) + else + frame = ChunkyPNG::Frame.new(fctl_chunk.width, fctl_chunk.height) + end + + [:x_offset, :y_offset, :delay_num, :delay_den, :dispose_op, :blend_op].each do |attr| + frame.send("#{attr}=", fctl_chunk.send(attr)) + end + frame + end + + # Initializes a new Frame instance. + # @param [Integer] width The width in pixels of this frame + # @param [Integer] height The height in pixels of this frame + # @param [Integer, Array, ...] initial The initial background or + # the initial pixel values. (see also: {ChunkyPNG::Canvas#initialize}) + # @param [Hash] attrs specification for rendering the frame + # @option attrs [Integer] :x_offset X position at which to render the frame. + # @option attrs [Integer] :y_offset Y position at which to render the frame. + # @option attrs [Integer] :delay_num Frame delay fraction numerator. + # @option attrs [Integer] :delay_den Frame delay fraction denominator. + # @option attrs [Integer] :dispose_op Type of frame area disposal to be done + # after rendering the frame. + # @option attrs [Integer] :blend_op Type of frame area rendering for the + # frame. + def initialize(width, height, initial = ChunkyPNG::Color::TRANSPARENT, attrs = {}) + super(width, height, initial) + attrs.each { |k, v| send("#{k}=", v) } + end + + # Returns fcTL/fdAT chunks which converted from Frame instance. + # @return [Array] + def to_chunks(seq_num = nil, constraints = {}) + [to_frame_control_chunk(seq_num), *to_frame_data_chunk(constraints)] + end + + # Returns fcTL chunks which converted from Frame instance. + # @return [ChunkyPNG::Chunk::FrameControl] + def to_frame_control_chunk(seq_num = nil) + @sequence_number = seq_num if seq_num + ChunkyPNG::Chunk::FrameControl.new( + :sequence_number => @sequence_number, + :width => @width, + :height => @height, + :x_offset => @x_offset, + :y_offset => @y_offset, + :delay_num => @delay_num, + :delay_den => @delay_den, + :dispose_op => @dispose_op, + :blend_op => @blend_op + ) + end + + # @return [Array] + # @see ChunkyPNG::Canvas::PNGEncoding#determine_png_encoding + def to_frame_data_chunk(constraints = {}) + encoding = determine_png_encoding(constraints) + data = encode_png_pixelstream(encoding[:color_mode], encoding[:bit_depth], + encoding[:interlace], encoding[:filtering]) + data_chunks = Chunk::ImageData.split_in_chunks(data, encoding[:compression]) + data_chunks.map.with_index do |data_chunk, idx| + attrs = { frame_data: data_chunk.content } + attrs[:sequence_number] = @sequence_number + idx + 1 if @sequence_number + ChunkyPNG::Chunk::FrameData.new(attrs) + end + end + end +end diff --git a/spec/chunky_png/animation_datastream_spec.rb b/spec/chunky_png/animation_datastream_spec.rb new file mode 100644 index 00000000..ebef6715 --- /dev/null +++ b/spec/chunky_png/animation_datastream_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe ChunkyPNG::AnimationDatastream do + describe '.from_file' do + it 'should read a stream without failing' do + filename = resource_file('2x2_loop_animation.png') + ads = ChunkyPNG::AnimationDatastream.from_file(filename) + expect(ads).to be_instance_of ChunkyPNG::AnimationDatastream + end + end + + describe '#each_chunk' do + let(:datastream) do + filename = resource_file('2x2_loop_animation.png') + ChunkyPNG::AnimationDatastream.from_file(filename) + end + let(:expected_types) do + %w(IHDR tEXt PLTE tRNS acTL fcTL IDAT fcTL fdAT fcTL fdAT fcTL fdAT IEND) + end + + it 'iterate chunks in frame order' do + types, seq_nums = [], [] + datastream.each_chunk do |chunk| + types << chunk.type + seq_nums << chunk.sequence_number if chunk.respond_to?(:sequence_number) + end + expect(types).to eql expected_types + expect(seq_nums).to eql (0..6).to_a + end + end + + describe '#default_image_is_first_frame?' do + let(:ds) { ChunkyPNG::AnimationDatastream.from_file(filename) } + subject { ds.default_image_is_first_frame? } + + context 'in case of APNG file which default image is first frame' do + let(:filename) { resource_file('2x2_loop_animation.png') } + it { is_expected.to eql true } + end + + context 'in case of APNG file which default image is not first frame' do + let(:filename) do + resource_file('2x2_loop_animation_without_default_image.png') + end + it { is_expected.to eql false } + end + end + + describe '#slice_frame_data_chunks' do + subject { ds.slice_frame_data_chunks(fctl_chunk) } + + let(:ds) do + filename = resource_file('2x2_loop_animation.png') + ChunkyPNG::AnimationDatastream.from_file(filename) + end + + context 'sequence number 0 (= default image)' do + let(:fctl_chunk) { ds.frame_control_chunks[0] } + it 'return empty array' do + expect(fctl_chunk.sequence_number).to eql 0 + is_expected.to eql [] + end + end + + context 'sequence number 1' do + let(:fctl_chunk) { ds.frame_control_chunks[1] } + it 'return fdat chunk whose sequence number 2' do + expect(fctl_chunk.sequence_number).to eql 1 + expected = ds.frame_data_chunks.select { |c| c.sequence_number == 2 } + is_expected.to eql expected + end + end + end +end diff --git a/spec/chunky_png/animation_spec.rb b/spec/chunky_png/animation_spec.rb new file mode 100644 index 00000000..c47d26ad --- /dev/null +++ b/spec/chunky_png/animation_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe ChunkyPNG::Animation do + describe '#initialize' do + it 'should use a transparent background by default' do + animation = ChunkyPNG::Animation.new(1, 1) + expect(animation[0,0]).to eql ChunkyPNG::Color::TRANSPARENT + end + + it 'should accept initial pixel values' do + animation = ChunkyPNG::Animation.new(2, 2, [1,2,3,4]) + expect(animation[0, 0]).to eql 1 + expect(animation[1, 0]).to eql 2 + expect(animation[0, 1]).to eql 3 + expect(animation[1, 1]).to eql 4 + end + + it 'should not accept pixel values as frame data by default' do + animation = ChunkyPNG::Animation.new(1, 1, 'red @ 0.8') + expect(animation.default_image_is_first_frame).to eql false + expect(animation.frames).to be_empty + end + + it 'should accept pixel values as first frame data with `first_frame` flag' do + animation = ChunkyPNG::Animation.new(1, 1, 'red @ 0.8', true) + expect(animation.default_image_is_first_frame).to eql true + expect(animation.frames.size).to eql 1 + expect(animation.frames.first).to be_instance_of ChunkyPNG::Frame + end + end + + describe '.from_frame' do + it 'should accept instance of ChunkyPNG::Frame as a first frame' do + frame = ChunkyPNG::Frame.new(1, 2, [1,2]) + animation = ChunkyPNG::Animation.from_frame(frame) + expect(animation.width).to eql 1 + expect(animation.height).to eql 2 + expect(animation.pixels).to eql [1,2] + expect(animation.default_image_is_first_frame).to eql true + expect(animation.frames.size).to eql 1 + expect(animation.frames.first).to eql frame + end + end + + describe '#num_frames' do + subject { animation.num_frames } + let(:animation) do + ChunkyPNG::Animation.new(1, 1, ChunkyPNG::Color::WHITE, false).tap do |a| + 3.times { a.frames << ChunkyPNG::Frame.new(1, 1, ChunkyPNG::Color::WHITE) } + end + end + + it 'should executes #size method of `@frames`' do + expect(animation.frames).to receive(:size).and_call_original + is_expected.to eql 3 + end + end + + describe '.from_file' do + context 'load apng file' do + it 'should read a stream without failing' do + filename = resource_file('2x2_loop_animation.png') + animation = ChunkyPNG::Animation.from_file(filename) + expect(animation).to be_instance_of ChunkyPNG::Animation + expect(animation.num_frames).to eql 4 + expect(animation.num_plays).to eql 0 + expect(animation.default_image_is_first_frame).to eql true + end + end + + context 'load not animated png file' do + it 'should read a stream without failing' do + filename = resource_file('clock.png') + animation = ChunkyPNG::Animation.from_file(filename) + expect(animation).to be_instance_of ChunkyPNG::Animation + expect(animation.num_frames).to eql 0 + expect(animation.num_plays).to eql 0 + expect(animation.default_image_is_first_frame).to eql false + end + end + end + + describe '#to_datastream' do + subject { animation.to_datastream } + let(:animation) do + ChunkyPNG::Animation.new(1, 1, ChunkyPNG::Color::WHITE, true).tap do |a| + 4.times { a.frames << ChunkyPNG::Frame.new(1, 1, ChunkyPNG::Color::WHITE) } + end + end + it 'should return animation datastream' do + expect(subject).to be_instance_of ChunkyPNG::AnimationDatastream + expect(subject.animation_control_chunk) + .to be_instance_of ChunkyPNG::Chunk::AnimationControl + expect(subject.frame_control_chunks.size).to eql 5 + expect(subject.frame_data_chunks.size).to eql 4 + expect(subject.frame_control_chunks.first) + .to be_instance_of ChunkyPNG::Chunk::FrameControl + expect(subject.frame_data_chunks.first) + .to be_instance_of ChunkyPNG::Chunk::FrameData + end + end +end diff --git a/spec/chunky_png/frame_spec.rb b/spec/chunky_png/frame_spec.rb new file mode 100644 index 00000000..301d2534 --- /dev/null +++ b/spec/chunky_png/frame_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe ChunkyPNG::Frame do + describe '.from_canvas' do + let(:canvas) { ChunkyPNG::Canvas.new(1, 1, ChunkyPNG::Color::WHITE) } + it 'should read a stream without failing' do + frame = ChunkyPNG::Frame.from_canvas(canvas, delay_num: 1, delay_den: 10) + expect(frame).to be_instance_of ChunkyPNG::Frame + expect(frame.width).to eql 1 + expect(frame.height).to eql 1 + expect(frame.delay_num).to eql 1 + expect(frame.delay_den).to eql 10 + end + end + + describe '.from_chunks' do + subject { ChunkyPNG::Frame.from_chunks(fctl_chunks, fdat_chunks, ds) } + + let(:ds) do + filename = resource_file('2x2_loop_animation.png') + ChunkyPNG::AnimationDatastream.from_file(filename) + end + + context 'initial frame (default image)' do + let(:fctl_chunks) { ds.frame_control_chunks.find { |c| c.sequence_number == 0 } } + let(:fdat_chunks) { [] } + it 'should read a stream without failing' do + is_expected.to be_instance_of ChunkyPNG::Frame + expect(subject.width).to eql 2 + expect(subject.height).to eql 2 + expect(subject.delay_num).to eql 1 + expect(subject.delay_den).to eql 30 + expect(subject.pixels).to eql [0, 0, 0, 0] + end + end + + context 'second frame' do + let(:fctl_chunks) { ds.frame_control_chunks.find { |c| c.sequence_number == 1 } } + let(:fdat_chunks) { ds.frame_data_chunks.select { |c| c.sequence_number == 2 } } + it 'should read a stream without failing' do + is_expected.to be_instance_of ChunkyPNG::Frame + expect(subject.width).to eql 1 + expect(subject.height).to eql 2 + expect(subject.delay_num).to eql 1 + expect(subject.delay_den).to eql 30 + end + end + end + + describe '#to_frame_control_chunk' do + subject { frame.to_frame_control_chunk(5) } + let(:frame) { ChunkyPNG::Frame.new(1, 1, ChunkyPNG::Color::WHITE, attrs) } + let(:attrs) { { delay_num: 1, delay_den: 30 } } + it do + is_expected.to be_instance_of ChunkyPNG::Chunk::FrameControl + expect(subject.sequence_number).to eql 5 + expect(subject.width).to eql 1 + expect(subject.height).to eql 1 + expect(subject.delay_num).to eql 1 + expect(subject.delay_den).to eql 30 + end + end + + describe '#to_frame_data_chunk' do + subject { frame.to_frame_data_chunk } + let(:frame) { ChunkyPNG::Frame.new(1, 1, ChunkyPNG::Color::WHITE, attrs) } + let(:attrs) { { delay_num: 1, delay_den: 30 } } + it do + is_expected.to be_instance_of Array + expect(subject.size).to eql 1 + expect(subject.first).to be_instance_of ChunkyPNG::Chunk::FrameData + end + end +end diff --git a/spec/resources/2x2_loop_animation.png b/spec/resources/2x2_loop_animation.png new file mode 100644 index 0000000000000000000000000000000000000000..7b1f85fd292b521023b23a7926fbd9c3529ffff2 GIT binary patch literal 403 zcmeAS@N?(olHy`uVBq!ia0vp^Od!m`3?yAM{)z!9&H$efS0MfW|9>D^e5|<;NHLcL z`2_=oTmr5s0=d#9t`Q}{`DrEPiAAXl&Z#-YmBk9dC8a5u`3fQV`8fxdKdb>N<48;n z@d45-K+I5cHthnCl1hV!frP+zgBW1I$RNkS@VWZ>8X%9))5S4_<9c$+uk$BP01Xgl zWR?5z!T~4))6WQGgD}Vp2mtA4+!NXD4&(}?r8tHFX{L%>$q6Y5Kt-GkERt_{&H%+= zCV>15(hV{N1Q>x3XoBnH{ilFDey9m7U=x7q*cn)Q3jb^XO2G_Z1+qZ~fG`Ar4Ui9B lQw!t@K@DI78IX{akN^w?eg+n{hD(tk#h$KyF6*2UngFIPQ4|0G literal 0 HcmV?d00001 diff --git a/spec/resources/2x2_loop_animation_without_default_image.png b/spec/resources/2x2_loop_animation_without_default_image.png new file mode 100644 index 0000000000000000000000000000000000000000..1aa6afa495c0504bbbf63f594e19ad000cc10060 GIT binary patch literal 285 zcmeAS@N?(olHy`uVBq!ia0vp^Od!k%Bp9O38gv3Fj>O~;A0W*P5{j6f5z zKqfE=FtToaIztC21~UQd43HWi1_4GO1e$Q~L8u&%#|Jfm8Ds*G-^S3q>xf-FkOkAv v0%XI?05X99tiMIUZ6%Nma{((*e^NpbP$YoCRsZ4ki6DiZu6{1-oD!M