diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be37d04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Chris Maddox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index cc04c5b..8944e60 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,27 @@ Imagineer ========= +**N.B.** +Until 1.0 is reached, each beta release my include backwards incompatible changes. +1.0 will include parsing and writing of PNGs and JPEGs. + Image parsing in Elixir. -Currently Imagineer only supports PNGs. To load an image, create a new `Imagineer.Image` and pass it to the load function. Once the image is processed (or the raw binary is placed in the `raw` field by some other means), passing it to `process` will parse all of its data. +Currently Imagineer only supports PNGs. To load an image, call the load function on the Imagineer module. Once the image is processed (or the raw binary is placed in the `raw` field by some other means), passing it to `process` will parse all of its data. ```elixir -alias Imagineer.Image -image = %Image{uri: "./test/support/images/alpaca.png"} |> - Image.load() |> - Image.process() +Imagineer.load("./test/support/images/png/alpaca.png") # => -# %Imagineer.Image{ -# alias: nil, -# attributes: %{ -# color_type: 2, -# compression: 0, -# filter_method: 0, -# interface_method: 0, -# pixel_dimensions: { -# 5669, -# 5669, -# :meter -# } -# }, -# bit_depth: 8, -# color_format: :rgb8, -# content: <<120, 1, 141, 189, 7, 148, 92, 213, 149, 254, 123, 99, 229, 208, 213, 57, 75, 106, 229, 0, 66, 66, 18, 32, 178, 49, 57, 216, 132, 193, 9, 99, 96, 108, 6, 131, 3, 14, 51, 255, 97, 198, 30, 71, 156, ...>>, -# format: :png, -# height: 96, -# mask: nil, -# raw: <<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 96, 0, 0, 0, 96, 8, 2, 0, 0, 0, 109, 250, 224, 111, 0, 0, 12, 70, 105, 67, 67, 80, ...>>, -# uri: "./test/support/images/alpaca.png", -# width: 96} +{:ok, + %Imagineer.Image.PNG{alias: nil, + attributes: %{"XML:com.adobe.xmp": "\n \n \n 96\n 96\n \n \n\n", + pixel_dimensions: {5669, 5669, :meter}}, bit_depth: 8, color_format: :rgb8, + color_type: 2, comment: nil, compression: :zlib, + data_content: <<120, 1, 141, 189, 7, 148, 92, 213, 149, 254, 123, 99, 229, 208, 213, 57, 75, 106, 229, 0, + 66, 66, 18, 32, 178, 49, 57, 216, 132, 193, 9, 99, 96, 108, 6, 131, 3, 14, 51, 255, 97, ...>>, + decompressed_data: nil, filter_method: :five_basics, format: :png, gamma: nil, + height: 96, interface_method: 0, mask: nil, palette: [], + pixels: [], # 96 rows of 96 3-element tuples each omitted for sanity. + raw: <<137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 96, 0, 0, 0, 96, 8, 2, 0, 0, #0, 109, 250, ...>>, + scanlines: [], unfiltered_rows: [], uri: nil, width: 96}} ``` diff --git a/config/config.exs b/config/config.exs index 6dfa82f..c6d35a4 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,24 +1,11 @@ -# This file is responsible for configuring your application -# and its dependencies with the aid of the Mix.Config module. use Mix.Config -# This configuration is loaded before any dependency and is restricted -# to this project. If another project depends on this project, this -# file won't be loaded nor affect the parent project. For this reason, -# if you want to provide default values for your application for third- -# party users, it should be done in your mix.exs file. +level = if System.get_env("DEBUG") do + :debug +else + :info +end -# Sample configuration: -# -# config :logger, :console, -# level: :info, -# format: "$date $time [$level] $metadata$message\n", -# metadata: [:user_id] - -# It is also possible to import configuration files, relative to this -# directory. For example, you can emulate configuration per environment -# by uncommenting the line below and defining dev.exs, test.exs and such. -# Configuration from the imported file will override the ones defined -# here (which is why it is important to import them last). -# -# import_config "#{Mix.env}.exs" +config :logger, :console, + level: level, + format: "$date $time [$level] $metadata$message\n" diff --git a/lib/imagineer.ex b/lib/imagineer.ex index 6b75548..08927c0 100644 --- a/lib/imagineer.ex +++ b/lib/imagineer.ex @@ -1,2 +1,33 @@ defmodule Imagineer do + alias Imagineer.FormatDetector + alias Imagineer.Image.PNG + alias Imagineer.Image.JPG + + @doc """ + Loads the file from the given location and processes it into the correct + file type. + Returns `{:ok, image}` if successful or `{:error, error_message}` otherwise + """ + @spec load(uri :: binary) :: {:ok, %{}} | {:error, binary} + def load(uri) do + case File.read(uri) do + {:ok, file} -> + detect_type_and_process(file) + {:error, reason} -> {:error, "Could not open #{uri} #{file_error_description(reason)}" } + end + end + + defp detect_type_and_process(content) do + case FormatDetector.detect(content) do + :png -> + {:ok, PNG.process(content)} + :jpg -> + {:ok, JPG.process(content)} + :unknown -> + {:error, "Unknown or unsupported image format."} + end + end + + defp file_error_description(:enoent), do: "because the file does not exist." + defp file_error_description(reason), do: "due to #{reason}." end diff --git a/lib/imagineer/format_detector.ex b/lib/imagineer/format_detector.ex new file mode 100644 index 0000000..b0ae2e2 --- /dev/null +++ b/lib/imagineer/format_detector.ex @@ -0,0 +1,9 @@ +defmodule Imagineer.FormatDetector do + @png_signature <<137::size(8), ?P, ?N, ?G, + 13::size(8), 10::size(8), 26::size(8), 10::size(8)>> + @jpg_signature <<255::size(8), 216::size(8)>> + + def detect(<<@png_signature, _rest::binary>>), do: :png + def detect(<<@jpg_signature, _rest::binary>>), do: :jpg + def detect(_), do: :unknown +end diff --git a/lib/imagineer/image.ex b/lib/imagineer/image.ex index 92a0659..0502578 100644 --- a/lib/imagineer/image.ex +++ b/lib/imagineer/image.ex @@ -1,40 +1,5 @@ defmodule Imagineer.Image do - defstruct alias: nil, width: nil, height: nil, mask: nil, bit_depth: nil, - color_format: nil, uri: nil, format: nil, attributes: %{}, content: <<>>, - raw: nil, comment: nil, mime_type: nil, components: nil - alias Imagineer.Image - alias Imagineer.Image.PNG - alias Imagineer.Image.JPG - - @png_signature <<137::size(8), 80::size(8), 78::size(8), 71::size(8), - 13::size(8), 10::size(8), 26::size(8), 10::size(8)>> - @jpg_signature <<255::size(8), 216::size(8)>> - - def load(%Image{uri: uri}=image) do - case File.read(uri) do - {:ok, file} -> - %Image{image | raw: file} - {:error, reason} -> {:error, "Could not open #{uri} due to #{reason}" } - end - end - - def process(%Image{format: :png}=image) do - PNG.process(image) - end - - def process(%Image{format: :jpg}=image) do - JPG.process(image) - end - - def process(%Image{raw: raw}=image) when not is_nil(raw) do - process %Image{ image | format: detect_format(image.raw) } - end - - defp detect_format(<<@png_signature, _png_body::binary>>) do - :png - end - - defp detect_format(<<@jpg_signature, _png_body::binary>>) do - :jpg - end + use Behaviour + @type supported_image :: %{} + defcallback process(raw_content :: bitstring) :: supported_image end diff --git a/lib/imagineer/image/jpg.ex b/lib/imagineer/image/jpg.ex index 0bf3b21..cac325c 100644 --- a/lib/imagineer/image/jpg.ex +++ b/lib/imagineer/image/jpg.ex @@ -1,5 +1,31 @@ defmodule Imagineer.Image.JPG do - alias Imagineer.Image + require Logger + alias Imagineer.Image.JPG + defstruct alias: nil, + width: nil, + height: nil, + bit_depth: nil, + color_type: nil, + color_format: nil, + uri: nil, + format: :jpg, + attributes: %{}, + data_content: <<>>, + raw: nil, + comment: "", + mask: nil, + compression: :dct, + decompressed_data: nil, + unfiltered_rows: nil, + scanlines: [], + filter_method: nil, + interface_method: nil, + gamma: nil, + palette: [], + pixels: [], + components: nil + + @behaviour Imagineer.Image @moduledoc """ YCbCr @@ -31,11 +57,24 @@ defmodule Imagineer.Image.JPG do @comment <<255::size(8), 254::size(8)>> - @start_of_baseline_frame <<255::size(8), 192::size(8)>> + @start_of_baseline_frame <<255::size(8), 192::size(8)>> + @start_of_progressive_frame <<255::size(8), 194::size(8)>> @define_huffman_table <<255::size(8), 196::size(8)>> @define_quantization_table <<255::size(8), 219::size(8)>> @start_of_scan <<255::size(8), 218::size(8)>> + @restart_interval <<255::size(8), 221::size(8)>> + @restart0 <<255::size(8), 208::size(8)>> + @restart1 <<255::size(8), 209::size(8)>> + @restart2 <<255::size(8), 210::size(8)>> + @restart3 <<255::size(8), 211::size(8)>> + @restart4 <<255::size(8), 212::size(8)>> + @restart5 <<255::size(8), 213::size(8)>> + @restart6 <<255::size(8), 214::size(8)>> + @restart7 <<255::size(8), 215::size(8)>> + + @padding <<0::size(8), 4::size(8)>> + @jfif_identifier <<74::size(8), 70::size(8), 73::size(8), 70::size(8)>> ## Colors @@ -43,74 +82,149 @@ defmodule Imagineer.Image.JPG do @kr 0.299 @kb 0.114 - def process(%Image{format: :jpg}=image) do - IO.puts inspect image.raw - process image, image.raw + def process(<<@start_of_image, rest::binary>>=raw) do + IO.puts "START" + process(rest, %JPG{raw: raw}) end - defp process(image, <<@start_of_image, rest::binary>>) do - process(image, rest) + def process(<<@start_of_image, rest::binary>>, %JPG{}=image) do + process(rest, image) end - defp process(image, <<@start_of_baseline_frame, rest::binary>>) do + def process(<<@start_of_baseline_frame, rest::binary>>, image) do + IO.puts "BASELINE" {content, rest} = marker_content(rest) - process_start_of_frame(content, image) - |> process(rest) + image = process_start_of_frame(content, image) + process(rest, image) end - defp process(image, <<@app0, rest::binary>>) do + def process(<<@start_of_progressive_frame, rest::binary>>, image) do + IO.puts "PROGRESSIVE" + {content, rest} = marker_content(rest) + image = process_start_of_frame(content, image) + process(rest, image) + end + + def process(<<@app0, rest::binary>>, image) do IO.puts "APP0" + Logger.debug("APP0") {marker_content, rest} = marker_content(rest) - process_app0(image, marker_content) - |> process(rest) + image = process_app0(image, marker_content) + process(rest, image) end - defp process(%Image{}=image, <<@define_quantization_table, rest::binary>>) do - IO.puts("DQT...skipping...") + def process(<<@define_quantization_table, rest::binary>>, %JPG{}=image) do + IO.puts "DQT" + Logger.debug("DQT...skipping...") {marker_content, rest} = marker_content(rest) - process_quantization_table(image, marker_content) - |> process(rest) + image = process_quantization_table(image, marker_content) + process(rest, image) end - # From [Wikipedia](http://en.wikibooks.org/wiki/JPEG_-_Idea_and_Practice/The_header_part#The_Quantization_table_segment_DQT): - # > A quantization table is specified in a DQT segment. A DQT segment begins with - # > the marker DQT = 219 and the length, which is (0, 67). Then comes a byte the - # > first half of which here is 0, meaning that the table consists of bytes - # > (8 bit numbers - for the extended mode it is 1, meaning that the table consists - # > of words, 16 bit numbers), and the last half of which is the destination - # > identifier of the table (0-3), for instance 0 for the Y component and 1 for the - # > colour components. Next follow the 64 numbers of the table (bytes). - defp process_quantization_table(image, content) do - image + def process(<<@app13, rest::binary>>, image) do + IO.puts "APP13" + Logger.debug("APP13!") + process(process_app13(rest, image), image) end - defp process(image, <<@app13, rest::binary>>) do - IO.puts "APP13!" + def process(<<@comment, rest::binary>>, image) do + IO.puts "COMMENT" + {marker_content, rest} = marker_content(rest) + image = %JPG{image | comment: image.comment <> marker_content} + process(rest, image) + end - process(image, process_app13(image, rest)) + def process(<<@padding, rest::binary>>, image) do + IO.puts "PADDING" + {marker_content, rest} = marker_content(rest) + process(rest, image) end - defp process(image, <<@comment, rest::binary>>) do + def process(<<@define_huffman_table, rest::binary>>, image) do + IO.puts "DFT" {marker_content, rest} = marker_content(rest) - %Image{image | comment: marker_content} - |> process(rest) + Logger.debug(marker_content, raw: true) + image = process_huffman_table(marker_content, image) + process(rest, image) + end + + def process(<<@restart_interval, marker::size(32), rest::binary>>, image) do + IO.puts "RESTART INTERVAL" + # IO.puts marker + process(rest, image) + end + + def process(<<@restart0, rest::binary>>, image) do + IO.puts "RESTART0" + process(rest, image) + end + + def process(<<@restart1, rest::binary>>, image) do + IO.puts "RESTART1" + process(rest, image) + end + + def process(<<@restart2, rest::binary>>, image) do + IO.puts "RESTART2" + process(rest, image) + end + + def process(<<@restart3, rest::binary>>, image) do + IO.puts "RESTART3" + process(rest, image) end - defp process(image, <<@define_huffman_table, rest::binary>>) do + def process(<<@restart4, rest::binary>>, image) do + IO.puts "RESTART4" + process(rest, image) + end + + def process(<<@restart5, rest::binary>>, image) do + IO.puts "RESTART5" + process(rest, image) + end + + def process(<<@restart6, rest::binary>>, image) do + IO.puts "RESTART6" + process(rest, image) + end + + def process(<<@restart7, rest::binary>>, image) do + IO.puts "RESTART7" + process(rest, image) + end + + def process(<<@start_of_scan, rest::binary>>, image) do + IO.puts "START SCAN" {marker_content, rest} = marker_content(rest) - IO.puts inspect(marker_content, raw: true) - process_huffman_table(marker_content, image) - |> process(rest) + process_start_of_scan(marker_content, image) + process(rest, image) end - defp process(image, <<@end_of_image>>) do + def process(<<@end_of_image>>, image) do + IO.puts "END" image end - defp process(image, <<255::size(8), marker::size(8), rest::binary>>) do - IO.puts "Skipping unknown marker #{marker}" - {marker_content, rest} = marker_content(rest) - process image, rest + def process(<<255::size(8), marker::size(8), rest::binary>>, image) do + IO.puts "UNKNOWN" + IO.puts marker + Logger.debug("Skipping unknown marker #{marker}") + # {marker_content, rest} = marker_content(rest) + process(rest, image) + end + + # From [Wikipedia](http://en.wikibooks.org/wiki/JPEG_-_Idea_and_Practice/The_header_part#The_Quantization_table_segment_DQT): + # > A quantization table is specified in a DQT segment. A DQT segment begins with + # > the marker DQT = 219 and the length, which is (0, 67). Then comes a byte the + # > first half of which here is 0, meaning that the table consists of bytes + # > (8 bit numbers - for the extended mode it is 1, meaning that the table consists + # > of words, 16 bit numbers), and the last half of which is the destination + # > identifier of the table (0-3), for instance 0 for the Y component and 1 for the + # > colour components. Next follow the 64 numbers of the table (bytes). + defp process_quantization_table(image, content) do + IO.puts "PQT" + image end # The leading byte tells us the number of bits to a color value. 8 is normal ( @@ -121,8 +235,18 @@ defmodule Imagineer.Image.JPG do image) do components = parse_components(components) - IO.puts "components: " <> inspect(components) - %Image{image | height: height, width: width, components: components} + %JPG{image | height: height, width: width, components: components} + end + + defp process_start_of_scan(<>, image) do + IO.puts "SoS" + # <<3, 1, 0, 2, 17, 3, 17, 0, 63, 0>> + component_size = num_components * 16 + <> = sos_rest + IO.puts spectral_start + IO.puts spectral_end + # components = parse_components(components) + # %JPG{image | components: components} end defp parse_components(components) do @@ -144,11 +268,12 @@ defmodule Imagineer.Image.JPG do ## DC Y Component defp process_huffman_table(<<0::size(4), 0::size(4), rest::binary>>, image) do + image end ## DC Color Component defp process_huffman_table(<<0::size(4), 1::size(4), rest::binary>>, image) do - + image end ## AC Y Component @@ -156,15 +281,14 @@ defmodule Imagineer.Image.JPG do image end - ## DC Color component defp process_huffman_table(<<1::size(4), 1::size(4), rest::binary>>, image) do image end - defp process_app13(image, bin) do - IO.puts "skipping APP13" - {_marker_content, rest} = marker_content(bin) + defp process_app13(rest, image) do + Logger.debug("skipping APP13") + {_marker_content, rest} = marker_content(rest) rest end @@ -175,15 +299,6 @@ defmodule Imagineer.Image.JPG do rest::binary>>) do thumbnail_data_size = 3 * thumbnail_width * thumbnail_height <> = rest - IO.puts "Data in app0:" <> inspect %{ - version_major: version_major, - version_minor: version_minor, - density_units: density_units, - x_density: x_density, - y_density: y_density, - thumbnail_data_size: thumbnail_data_size, - thumbnail_data: thumbnail_data - } image end @@ -192,6 +307,8 @@ defmodule Imagineer.Image.JPG do # two to get the length of the actual content defp marker_content(<>) do content_length = total_length - 2 + IO.puts "CONTENT LENGTH" + IO.puts content_length <> = rest {content, rest} end diff --git a/lib/imagineer/image/jpg/compression.ex b/lib/imagineer/image/jpg/compression.ex new file mode 100644 index 0000000..0bef94f --- /dev/null +++ b/lib/imagineer/image/jpg/compression.ex @@ -0,0 +1,20 @@ +defmodule Imagineer.Image.JPG.Compression do + alias Imagineer.Image.JPG + @moduledoc """ + Handles + """ + + @doc """ + Decompresses JPG data based on the passed in compression method. + + Currently only dct is supported. + """ + def decompress(%JPG{compression: compression}=image) do + decompressed_data = decompress(compression, image) + Map.put(image, :decompressed_data, decompressed_data) + end + + defp decompress(:dct, image) do + JPG.Compression.DCT.decompress(image) + end +end diff --git a/lib/imagineer/image/jpg/compression/dct.ex b/lib/imagineer/image/jpg/compression/dct.ex new file mode 100644 index 0000000..6001c23 --- /dev/null +++ b/lib/imagineer/image/jpg/compression/dct.ex @@ -0,0 +1,26 @@ +defmodule Imagineer.Image.JPG.Compression.DCT do + alias Imagineer.Image.JPG + + @doc """ + Takes in a JPG and returns its `data_content` inflated with dct. + + ## Example + + Let's get real deep with some Tallest Man on Earth. + + iex> alias Imagineer.Image.JPG + iex> JPG.Compression.DCT.decompress(%JPG{ + ...> data_content: <<120, 156, 29, 140, 65, 14, 128, 32, 12, 4, 191, 178, + ...> 55, 46, 198, 15, 112, 242, 200, 51, 208, 86, 37, 129, 150, 88, 136, + ...> 241, 247, 162, 215, 217, 217, 89, 132, 208, 206, 100, 176, 72, 194, + ...> 102, 19, 2, 172, 215, 170, 198, 30, 3, 31, 42, 18, 113, 106, 38, + ...> 20, 70, 211, 33, 51, 142, 75, 187, 144, 199, 50, 206, 193, 21, 236, + ...> 122, 109, 76, 223, 186, 167, 191, 199, 176, 150, 114, 246, 8, 130, + ...> 136, 154, 227, 198, 120, 180, 227, 86, 113, 13, 43, 195, 253, 133, + ...> 249, 5, 207, 168, 43, 42>> + ...> }) + "And this sadness, I suppose; is gonna hold me to the ground; And I'm forced to find the still; In a place you won't be 'round." + """ + def decompress(%JPG{data_content: compressed_data}) do + end +end diff --git a/lib/imagineer/image/jpg/data_content.ex b/lib/imagineer/image/jpg/data_content.ex new file mode 100644 index 0000000..3ff145c --- /dev/null +++ b/lib/imagineer/image/jpg/data_content.ex @@ -0,0 +1,70 @@ +defmodule Imagineer.Image.JPG.DataContent do + alias Imagineer.Image.JPG + # import Imagineer.Image.JPG.Helpers, only: [bytes_per_row: 2] + + @doc """ + Takes in an image whose chunks have been processed and decompresses, + defilters, de-interlaces, and pulls apart its pixels. + """ + def process(%JPG{}=image) do + JPG.Compression.decompress(image) + |> extract_scanlines() + |> JPG.Filter.unfilter + |> JPG.Pixels.extract + |> cleanup + end + + # Removes fields that are used in intermediate steps that don't make sense + # otherwise + defp cleanup(%JPG{}=image) do + %JPG{image | scanlines: [], decompressed_data: nil, unfiltered_rows: []} + end + + @doc """ + Takes in a JPG with decompressed data and splits up the individual scanlines, + returning the image with scanlines attached. + A scanline is 1 byte containing the filter followed by the binary + representation of that row of pixels (as filtered through the filter indicated + by the first byte.) + + ## Example + + iex> alias Imagineer.Image.JPG + iex> decompressed_data = <<1, 127, 138, 255, 20, 21, 107, 0, 233, 1, 77, + ...> 78, 191, 144, 2, 1, 77, 16, 234, 234, 154, 3, 67, 123, 98, 142, + ...> 117, 3, 4, 104, 44, 87, 33, 91, 188>> + iex> updated_image = %JPG{ + ...> decompressed_data: decompressed_data, + ...> color_format: :rgb8, + ...> width: 2 + ...> } |> + ...> JPG.DataContent.extract_scanlines() + iex> updated_image.scanlines + [ + <<1, 127, 138, 255, 20, 21, 107>>, + <<0, 233, 1, 77, 78, 191, 144>>, + <<2, 1, 77, 16, 234, 234, 154>>, + <<3, 67, 123, 98, 142, 117, 3>>, + <<4, 104, 44, 87, 33, 91, 188>> + ] + """ + def extract_scanlines(%JPG{decompressed_data: decompressed_data}=image) do + scanlines = extract_scanlines(decompressed_data, bytes_per_scanline(image), []) + Map.put(image, :scanlines, scanlines) + end + + defp extract_scanlines(<<>>, _, scanlines) do + Enum.reverse scanlines + end + + defp extract_scanlines(decompressed_data, bytes_per_scanline, scanlines) do + <> = decompressed_data + extract_scanlines(rest, bytes_per_scanline, [scanline | scanlines]) + end + + # The number of bytes per scanline is equal to the number of bytes per row + # plus one byte for the filter method. + defp bytes_per_scanline(%JPG{color_format: color_format, width: width}) do + # bytes_per_row(color_format, width) + 1 + end +end diff --git a/lib/imagineer/image/png.ex b/lib/imagineer/image/png.ex index a23b252..22cb79d 100644 --- a/lib/imagineer/image/png.ex +++ b/lib/imagineer/image/png.ex @@ -1,138 +1,221 @@ defmodule Imagineer.Image.PNG do - alias Imagineer.Image + require Logger + alias Imagineer.Image.PNG + import Imagineer.Image.PNG.Helpers + defstruct alias: nil, + width: nil, + height: nil, + bit_depth: nil, + color_type: nil, + color_format: nil, + uri: nil, + format: :png, + attributes: %{}, + data_content: <<>>, + raw: nil, + comment: nil, + mask: nil, + compression: :zlib, + decompressed_data: nil, + unfiltered_rows: nil, + scanlines: [], + filter_method: nil, + interface_method: nil, + gamma: nil, + palette: [], + pixels: [] - @png_signiture <<137::size(8), 80::size(8), 78::size(8), 71::size(8), - 13::size(8), 10::size(8), 26::size(8), 10::size(8)>> + @behaviour Imagineer.Image + + @png_signature <<137::size(8), ?P, ?N, ?G, ?\r, ?\n, 26::size(8), ?\n>> # Required headers - @ihdr_header <<73::size(8), 72::size(8), 68::size(8), 82::size(8)>> - @plte_header <<80::size(8), 76::size(8), 84::size(8), 69::size(8)>> - @idat_header <<73::size(8), 68::size(8), 65::size(8), 84::size(8)>> - @iend_header <<73::size(8), 69::size(8), 78::size(8), 68::size(8)>> + @ihdr_header <> + @plte_header <> + @idat_header <> + @iend_header <> # Auxillary headers - @bkgd_header <<98::size(8), 75::size(8), 82::size(8), 68::size(8)>> - @iccp_header <<105::size(8), 67::size(8), 67::size(8), 80::size(8)>> - @phys_header <<112::size(8), 72::size(8), 89::size(8), 115::size(8)>> - @text_header <<105::size(8), 84::size(8), 88::size(8), 116::size(8)>> + @bkgd_header <> + @iccp_header <> + @phys_header <> + @itxt_header <> + @gama_header <> + + # Compression + @zlib 0 + + # Filter Methods + @filter_five_basics 0 - @mime_type "image/png" + # Filter Types + # http://www.w3.org/TR/PNG-Filters.html + @filter_0 :none + @filter_1 :sub + @filter_2 :up + @filter_3 :average + @filter_4 :paeth + + # Color Types + # Color type is a single-byte integer that describes the interpretation of the + # image data. Color type codes represent sums of the following values: + # - 1 (palette used) + # - 2 (color used) + # - 4 (alpha channel used) + # Valid values are 0, 2, 3, 4, and 6. + @color_type_raw 0 + @color_type_palette 1 + @color_type_color 2 + @color_type_palette_and_color 3 + @color_type_alpha 4 + @color_type_palette_color_and_alpha 6 + + def process(<<@png_signature, rest::binary>>=raw) do + process(rest, %PNG{raw: raw}) + end - def process(%Image{format: :png, raw: <<@png_signiture, rest::binary>>}=image) do - IO.puts("processing") - %Image{ image | mime_type: @mime_type } - |> process(rest) + def process(<<@png_signature, rest::binary>>, %PNG{}=image) do + process(rest, image) end # Processes the "IHDR" chunk - def process(%Image{} = image, <>) do + def process(<>, %PNG{} = image) do <> = content - attributes = Map.merge image.attributes, %{ + image = %PNG{ + image | + width: width, + height: height, + bit_depth: bit_depth, + color_format: color_format(color_type, bit_depth), color_type: color_type, - compression: compression, - filter_method: filter_method, + compression: compression_format(compression), + filter_method: filter_method(filter_method), interface_method: interface_method } - - image = %Image{ image | attributes: attributes, width: width, height: height, bit_depth: bit_depth, color_format: color_format(color_type, bit_depth) } - process(image, rest) + process(rest, image) end # Process "PLTE" chunk - def process(%Image{} = image, <>) do - image = %Image{ image | attributes: set_attribute(image, :palette, read_pallete(content))} - process(image, rest) + def process(<>,%PNG{}=image) do + image = %PNG{ image | palette: read_palette(content)} + process(rest, image) end # Process "pHYs" chunk - def process(%Image{} = image, <<_content_length::integer-size(32), @phys_header, - <>, - _crc::size(32), rest::binary >>) do + def process(<<_content_length::integer-size(32), @phys_header, + x_pixels_per_unit::integer-size(32), y_pixels_per_unit::integer-size(32), + _unit::binary-size(1), _crc::size(32), rest::binary >>, %PNG{}=image) do pixel_dimensions = { x_pixels_per_unit, y_pixels_per_unit, :meter} - image = %Image{ image | attributes: set_attribute(image, :pixel_dimensions, pixel_dimensions)} - process(image, rest) + image = %PNG{ image | attributes: set_attribute(image, :pixel_dimensions, pixel_dimensions)} + process(rest, image) end # Process the "IDAT" chunk # There can be multiple IDAT chunks to allow the encoding system to control # memory consumption. Append the content - def process(%Image{} = image, <>) do - process(%Image{ image | content: image.content <> content}, rest) + def process(<>, image) do + new_content = image.data_content <> data_content + process(rest, Map.put(image, :data_content, new_content)) end # Process the "IEND" chunk # The end of the PNG - def process(%Image{} = image, <<_length::size(32), @iend_header, _rest::binary>>) do - image + def process(<<_length::size(32), @iend_header, _rest::binary>>, %PNG{}=image) do + PNG.DataContent.process(image) end # Process the auxillary "bKGD" chunk - def process(%Image{} = image, <>) do - color_type = image.attributes.color_type - background_color = case content do - <> when color_type == 3 -> - index - <> when color_type == 0 or color_type == 4 -> - gray - <> when color_type == 2 or color_type == 6 -> - {red, green, blue} - _ -> - :undefined - end - image = %Image{ image | attributes: set_attribute(image, :background_color, background_color)} - process(image, rest) + def process( + <<_content_length::size(32), @bkgd_header, gray::size(16), _crc::size(32), rest::binary>>, + %PNG{color_type: @color_type_raw}=image) + do + process_with_background_color(image, gray, rest) end - # Process the auxillary "tEXt" chunk - def process(%Image{} = image, <>) do + def process( + <<_content_length::size(32), @bkgd_header, red::size(16), green::size(16), blue::size(16), _crc::size(32), rest::binary>>, + %PNG{color_type: @color_type_color}=image) + do + process_with_background_color(image, {red, green, blue}, rest) + end + + def process( + <<_content_length::size(32), @bkgd_header, index::size(8), _crc::size(32), rest::binary>>, + %PNG{color_type: @color_type_palette_and_color}=image) + do + process_with_background_color(image, elem(image.palatte, index), rest) + end + + def process( + <<_content_length::size(32), @bkgd_header, gray::size(16), _crc::size(32), rest::binary>>, + %PNG{color_type: @color_type_alpha}=image) + do + process_with_background_color(image, gray, rest) + end + + def process( + <<_content_length::size(32), @bkgd_header, red::size(16), green::size(16), blue::size(16), _crc::size(32), rest::binary>>, + %PNG{color_type: @color_type_palette_color_and_alpha}=image) + do + process_with_background_color(image, {red, green, blue}, rest) + end + + # Process the auxillary "gAMA" chunk + def process( + <<_content_length::size(32), @gama_header, gamma::integer-size(32), _crc::size(32), rest::binary>>, + %PNG{}=image) + do + process(rest, %PNG{image| gamma: gamma/100_000}) + end + + # Process the auxillary "iTXt" chunk + def process(<>, %PNG{}=image) do image = process_text_chunk(image, content) - process(image, rest) + process(rest, image) end # For headers that we don't understand, skip them - def process(%Image{} = image, <>) do - IO.puts("Don't understand what to do with #{header}") - process(image, rest) + def process(<>, + %PNG{}=image) do + Logger.debug("Skipping unknown header #{header}") + process(rest, image) + end + + defp process_with_background_color(background_color, image, rest) do + image = %PNG{ image | attributes: set_attribute(image, :background_color, background_color)} + process(rest, image) end # Private helper functions - defp set_attribute(%Image{} = image, attribute, value) do + defp set_attribute(%PNG{} = image, attribute, value) do Map.put image.attributes, attribute, value end - # Color formats, taking in the color_type and bit_depth - defp color_format(0, 1) , do: :grayscale1 - defp color_format(0, 2) , do: :grayscale2 - defp color_format(0, 4) , do: :grayscale4 - defp color_format(0, 8) , do: :grayscale8 - defp color_format(0, 16), do: :grayscale16 - defp color_format(2, 8) , do: :rgb8 - defp color_format(2, 16), do: :rgb16 - defp color_format(3, 1) , do: :palette1 - defp color_format(3, 2) , do: :palette2 - defp color_format(3, 4) , do: :palette4 - defp color_format(3, 8) , do: :palette8 - defp color_format(4, 8) , do: :grayscale_alpha8 - defp color_format(4, 16), do: :grayscale_alpha16 - defp color_format(6, 8) , do: :rgb_alpha8 - defp color_format(6, 16), do: :rgb_alpha16 + # Check the compression byte. Purposefully raise if not zlib + defp compression_format(@zlib), do: :zlib - defp read_pallete(content) do - Enum.reverse read_pallete(content, []) + # Check for the filter method. Purposefully raise if not the only one defined + defp filter_method(@filter_five_basics), do: :five_basics + + # We store as an array because we need to access by index + defp read_palette(content) do + read_palette(content, []) + |> Enum.reverse + |> :array.from_list end - defp read_pallete(<>, acc) do - read_pallete(more_pallete, [{red, green, blue}| acc]) + defp read_palette(<>, acc) do + read_palette(more_palette, [{red, green, blue}| acc]) end defp process_text_chunk(image, content) do @@ -165,15 +248,14 @@ defmodule Imagineer.Image.PNG do content end - # Sets the attribute relevant to whatever is held in the text chunk, # returns the image defp set_text_attribute(image, key, value) do case key do :Comment -> - %Image{image | comment: value} + %PNG{image | comment: value} _ -> - %Image{image | attributes: set_attribute(image, key, value)} + %PNG{image | attributes: set_attribute(image, key, value)} end end diff --git a/lib/imagineer/image/png/compression.ex b/lib/imagineer/image/png/compression.ex new file mode 100644 index 0000000..4f40acf --- /dev/null +++ b/lib/imagineer/image/png/compression.ex @@ -0,0 +1,20 @@ +defmodule Imagineer.Image.PNG.Compression do + alias Imagineer.Image.PNG + @moduledoc """ + Handles + """ + + @doc """ + Decompresses PNG data based on the passed in compression method. + + Currently only zlib is supported. + """ + def decompress(%PNG{compression: compression}=image) do + decompressed_data = decompress(compression, image) + Map.put(image, :decompressed_data, decompressed_data) + end + + defp decompress(:zlib, image) do + PNG.Compression.Zlib.decompress(image) + end +end diff --git a/lib/imagineer/image/png/compression/zlib.ex b/lib/imagineer/image/png/compression/zlib.ex new file mode 100644 index 0000000..fcac39d --- /dev/null +++ b/lib/imagineer/image/png/compression/zlib.ex @@ -0,0 +1,32 @@ +defmodule Imagineer.Image.PNG.Compression.Zlib do + alias Imagineer.Image.PNG + + @doc """ + Takes in a PNG and returns its `data_content` inflated with zlib. + + ## Example + + Let's get real deep with some Tallest Man on Earth. + + iex> alias Imagineer.Image.PNG + iex> PNG.Compression.Zlib.decompress(%PNG{ + ...> data_content: <<120, 156, 29, 140, 65, 14, 128, 32, 12, 4, 191, 178, + ...> 55, 46, 198, 15, 112, 242, 200, 51, 208, 86, 37, 129, 150, 88, 136, + ...> 241, 247, 162, 215, 217, 217, 89, 132, 208, 206, 100, 176, 72, 194, + ...> 102, 19, 2, 172, 215, 170, 198, 30, 3, 31, 42, 18, 113, 106, 38, + ...> 20, 70, 211, 33, 51, 142, 75, 187, 144, 199, 50, 206, 193, 21, 236, + ...> 122, 109, 76, 223, 186, 167, 191, 199, 176, 150, 114, 246, 8, 130, + ...> 136, 154, 227, 198, 120, 180, 227, 86, 113, 13, 43, 195, 253, 133, + ...> 249, 5, 207, 168, 43, 42>> + ...> }) + "And this sadness, I suppose; is gonna hold me to the ground; And I'm forced to find the still; In a place you won't be 'round." + """ + def decompress(%PNG{data_content: compressed_data}) do + zlib_stream = :zlib.open() + :ok = :zlib.inflateInit(zlib_stream) + decompressed_data = Enum.join(:zlib.inflate(zlib_stream, compressed_data), "") + :ok = :zlib.inflateEnd(zlib_stream) + :ok = :zlib.close(zlib_stream) + decompressed_data + end +end diff --git a/lib/imagineer/image/png/data_content.ex b/lib/imagineer/image/png/data_content.ex new file mode 100644 index 0000000..3d80218 --- /dev/null +++ b/lib/imagineer/image/png/data_content.ex @@ -0,0 +1,70 @@ +defmodule Imagineer.Image.PNG.DataContent do + alias Imagineer.Image.PNG + import Imagineer.Image.PNG.Helpers, only: [bytes_per_row: 2] + + @doc """ + Takes in an image whose chunks have been processed and decompresses, + defilters, de-interlaces, and pulls apart its pixels. + """ + def process(%PNG{}=image) do + PNG.Compression.decompress(image) + |> extract_scanlines() + |> PNG.Filter.unfilter + |> PNG.Pixels.extract + |> cleanup + end + + # Removes fields that are used in intermediate steps that don't make sense + # otherwise + defp cleanup(%PNG{}=image) do + %PNG{image | scanlines: [], decompressed_data: nil, unfiltered_rows: []} + end + + @doc """ + Takes in a PNG with decompressed data and splits up the individual scanlines, + returning the image with scanlines attached. + A scanline is 1 byte containing the filter followed by the binary + representation of that row of pixels (as filtered through the filter indicated + by the first byte.) + + ## Example + + iex> alias Imagineer.Image.PNG + iex> decompressed_data = <<1, 127, 138, 255, 20, 21, 107, 0, 233, 1, 77, + ...> 78, 191, 144, 2, 1, 77, 16, 234, 234, 154, 3, 67, 123, 98, 142, + ...> 117, 3, 4, 104, 44, 87, 33, 91, 188>> + iex> updated_image = %PNG{ + ...> decompressed_data: decompressed_data, + ...> color_format: :rgb8, + ...> width: 2 + ...> } |> + ...> PNG.DataContent.extract_scanlines() + iex> updated_image.scanlines + [ + <<1, 127, 138, 255, 20, 21, 107>>, + <<0, 233, 1, 77, 78, 191, 144>>, + <<2, 1, 77, 16, 234, 234, 154>>, + <<3, 67, 123, 98, 142, 117, 3>>, + <<4, 104, 44, 87, 33, 91, 188>> + ] + """ + def extract_scanlines(%PNG{decompressed_data: decompressed_data}=image) do + scanlines = extract_scanlines(decompressed_data, bytes_per_scanline(image), []) + Map.put(image, :scanlines, scanlines) + end + + defp extract_scanlines(<<>>, _, scanlines) do + Enum.reverse scanlines + end + + defp extract_scanlines(decompressed_data, bytes_per_scanline, scanlines) do + <> = decompressed_data + extract_scanlines(rest, bytes_per_scanline, [scanline | scanlines]) + end + + # The number of bytes per scanline is equal to the number of bytes per row + # plus one byte for the filter method. + defp bytes_per_scanline(%PNG{color_format: color_format, width: width}) do + bytes_per_row(color_format, width) + 1 + end +end diff --git a/lib/imagineer/image/png/filter.ex b/lib/imagineer/image/png/filter.ex new file mode 100644 index 0000000..ccd71c9 --- /dev/null +++ b/lib/imagineer/image/png/filter.ex @@ -0,0 +1,17 @@ +defmodule Imagineer.Image.PNG.Filter do + alias Imagineer.Image.PNG + + @doc """ + Takes in a png and converts its `scanlines` into `unfiltered_rows`. Returns + the png with the `unfiltered_rows` attribute set to a list of tuples of the + for `{row_index, unfiltered_row_binary}` + """ + def unfilter(%PNG{filter_method: filter_method}=image) do + unfiltered_rows = unfilter(filter_method, image) + Map.put(image, :unfiltered_rows, unfiltered_rows) + end + + defp unfilter(:five_basics, %PNG{}=image) do + PNG.Filter.Basic.unfilter(image) + end +end diff --git a/lib/imagineer/image/png/filter/basic.ex b/lib/imagineer/image/png/filter/basic.ex new file mode 100644 index 0000000..4ba5cd4 --- /dev/null +++ b/lib/imagineer/image/png/filter/basic.ex @@ -0,0 +1,51 @@ +defmodule Imagineer.Image.PNG.Filter.Basic do + alias Imagineer.Image.PNG + alias PNG.Filter.Basic + import PNG.Helpers, only: [ bytes_per_pixel: 1, bytes_per_row: 2, null_binary: 1 ] + @none 0 + @sub 1 + @up 2 + @average 3 + @paeth 4 + + @doc """ + Takes an image and its decompressed content. Returns the rows unfiltered with + their respective index. + + Types are defined [here](http://www.w3.org/TR/PNG-Filters.html). + """ + def unfilter(%PNG{scanlines: scanlines}=image) when is_list(scanlines) do + # For unfiltering, the row prior to the first is assumed to be all 0s + ghost_row = null_binary(bytes_per_row(image.color_format, image.width)) + unfilter(scanlines, ghost_row, 0, bytes_per_pixel(image.color_format), []) + end + + defp unfilter([], _prior_row, _current_index, _bytes_per_pixel, unfiltered) do + Enum.reverse unfiltered + end + + defp unfilter([filtered_row | filtered_rows], prior_row, row_index, bytes_per_pixel, unfiltered) do + unfiltered_row = unfilter_scanline(filtered_row, bytes_per_pixel, prior_row) + unfilter(filtered_rows, unfiltered_row, row_index+1, bytes_per_pixel, [{row_index, unfiltered_row} | unfiltered]) + end + + defp unfilter_scanline(<<@none::size(8), row_content::binary>>, _bytes_per_pixel, _prior) do + row_content + end + + defp unfilter_scanline(<<@sub::size(8), row_content::binary>>, bytes_per_pixel, _prior) do + Basic.Sub.unfilter(row_content, bytes_per_pixel) + end + + defp unfilter_scanline(<<@up::size(8), row_content::binary>>, _bytes_per_pixel, prior_row) do + Basic.Up.unfilter(row_content, prior_row) + end + + defp unfilter_scanline(<<@average::size(8), row_content::binary>>, bytes_per_pixel, prior_row) do + Basic.Average.unfilter(row_content, prior_row, bytes_per_pixel) + end + + defp unfilter_scanline(<<@paeth::size(8), row_content::binary>>, bytes_per_pixel, prior_row) do + Basic.Paeth.unfilter(row_content, prior_row, bytes_per_pixel) + end +end diff --git a/lib/imagineer/image/png/filter/basic/average.ex b/lib/imagineer/image/png/filter/basic/average.ex new file mode 100644 index 0000000..4756107 --- /dev/null +++ b/lib/imagineer/image/png/filter/basic/average.ex @@ -0,0 +1,79 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.Average do + import Imagineer.Image.PNG.Helpers, only: [ null_binary: 1 ] + @moduledoc """ + The Average filter computes value for a pixel based on the average between the + pixel to its left and the pixel directly above it. + """ + + @doc """ + Takes in the uncompressed binary for an average-filtered row of pixels, the + unfiltered binary of the preceding row, and the number of bytes per pixel. It + returns the a binary of the row as unfiltered pixel data. + + For more information, see the PNG Filter [documentation for the Average filter type + ](http://www.w3.org/TR/PNG-Filters.html#Filter-type-3-Average). + + ## Example + + iex> filtered = <<13, 191, 74, 228, 149, 158>> + iex> prior_unfiltered_row = <<8, 215, 35, 113, 28, 112>> + iex> Imagineer.Image.PNG.Filter.Basic.Average.unfilter(filtered, prior_unfiltered_row, 3) + <<17, 42, 91, 37, 184, 3>> + + iex> filtered = <<13, 191, 74, 228, 149, 158>> + iex> prior_unfiltered_row = <<8, 215, 35, 113, 28, 112>> + iex> Imagineer.Image.PNG.Filter.Basic.Average.unfilter(filtered, prior_unfiltered_row, 2) + <<17, 42, 100, 49, 213, 238>> + """ + def unfilter(row, prior_row, bytes_per_pixel) do + # For the first row, the preceding pixel bytes are all zeros + ghost_pixel = null_binary(bytes_per_pixel) + unfilter(row, prior_row, ghost_pixel, bytes_per_pixel, []) + |> Enum.join + end + + # In the base case, `unfiltered_pixels` will be a reversed list of lists, each + # sublist of which contains the unfiltered bytes for that pixel + defp unfilter(<<>>, <<>>, _prior_pixel, _bytes_per_pixel, unfiltered_pixels) do + Enum.reverse unfiltered_pixels + end + + defp unfilter(row, prior_row, prior_pixel, bytes_per_pixel, unfiltered_pixels) do + <> = row + <> = prior_row + unfiltered_pixel = unfilter_pixel(row_pixel, prior_row_pixel, prior_pixel) + unfilter(row_rest, prior_row_rest, unfiltered_pixel, bytes_per_pixel, [unfiltered_pixel | unfiltered_pixels]) + end + + defp unfilter_pixel(row_pixel, prior_row_pixel, prior_pixel) do + unfilter_pixel(row_pixel, prior_row_pixel, prior_pixel, []) + |> Enum.join + end + + # In the base case, `unfiltered_bytes` is a reveresed list of the unfiltered + # bytes for this one pixel + defp unfilter_pixel(<<>>, <<>>, <<>>, unfiltered_bytes) do + Enum.reverse unfiltered_bytes + end + + # To unfilter a given pixel's byte, we take the average of the corresponding + # byte of the pixel above it (`prior_row_pixel`) and the the corresponding + # byte of the pixel immediately to its left (`prior_pixel`.) We then take the + # floor of the average of these two and add it to our filtered byte ( + # `row_pixel_byte`. That number modulo 256 (so it fits into a byte) is our + # answer. + # + # Who comes up with this shit? + defp unfilter_pixel( + <>, + <>, + <>, + unfiltered_bytes) + do + # We use `Kernel.trunc` to turn get the floor of the average as an integer + unfiltered_byte = row_pixel_byte + trunc((prior_pixel_byte + prior_row_pixel_byte)/2) + unfiltered_bytes = [<> | unfiltered_bytes] + unfilter_pixel(row_pixel_rest, prior_row_pixel_rest, prior_pixel_rest, unfiltered_bytes) + end + +end diff --git a/lib/imagineer/image/png/filter/basic/paeth.ex b/lib/imagineer/image/png/filter/basic/paeth.ex new file mode 100644 index 0000000..9f25361 --- /dev/null +++ b/lib/imagineer/image/png/filter/basic/paeth.ex @@ -0,0 +1,117 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.Paeth do + import Imagineer.Image.PNG.Helpers, only: [ null_binary: 1 ] + + @doc """ + Takes in the uncompressed binary representation of a row, the unfiltered row + row above it, and the number of bytes per pixel. Decodes according to the + Paeth filter. + + For more information, see the PNG documentation for the [Paeth filter type] + (http://www.w3.org/TR/PNG-Filters.html#Filter-type-4-Paeth) + + ## Examples + + iex> unfiltered_prior_row = <<18, 39, 117, 39, 201, 7>> + iex> filtered_row = <<86, 5, 226, 185, 146, 181>> + iex> Imagineer.Image.PNG.Filter.Basic.Paeth.unfilter(filtered_row, unfiltered_prior_row, 3) + <<104, 44, 87, 33, 91, 188>> + + iex> unfiltered_prior_row = <<18, 39, 117, 39, 201, 7>> + iex> filtered_row = <<86, 5, 226, 245, 146, 181>> + iex> Imagineer.Image.PNG.Filter.Basic.Paeth.unfilter(filtered_row, unfiltered_prior_row, 2) + <<104, 44, 87, 33, 91, 188>> + """ + def unfilter(row, prior_row, bytes_per_pixel) do + # For the first pixel, which has no upper left or left, we fill them in as + # null-filled binaries (`<<0>>`.) + upper_left_ghost_pixel = left_ghost_pixel = null_binary(bytes_per_pixel) + unfilter(row, prior_row, left_ghost_pixel, upper_left_ghost_pixel, bytes_per_pixel, []) + |> Enum.join() + end + + # In the base case, we'll have a reversed list of binaries, each containing + # the unfiltered bytes of their respective pixel + defp unfilter(<<>>, <<>>, _left_pixel, _upper_left_pixel, _bytes_per_pixel, unfiltered_pixels) do + Enum.reverse unfiltered_pixels + end + + defp unfilter(row, prior_row, left_pixel, upper_left_pixel, bytes_per_pixel, unfiltered_pixels) do + <> = row + <> = prior_row + unfiltered_pixel = unfilter_pixel(row_pixel, left_pixel, above_pixel, upper_left_pixel) + unfilter(row_rest, prior_row_rest, unfiltered_pixel, above_pixel, bytes_per_pixel, [unfiltered_pixel | unfiltered_pixels]) + end + + defp unfilter_pixel(row_pixel, left_pixel, above_pixel, upper_left_pixel) do + unfilter_pixel(row_pixel, left_pixel, above_pixel, upper_left_pixel, []) + |> Enum.join() + end + + # In the base case, we'll have run through each of the bytes and have a + # reversed list of unfiltered bytes + defp unfilter_pixel(<<>>, <<>>, <<>>, <<>>, unfiltered_pixel_bytes) do + Enum.reverse unfiltered_pixel_bytes + end + +# Paeth(x) + PaethPredictor(Raw(x-bpp), Prior(x), Prior(x-bpp)) + + defp unfilter_pixel( + <>, + <>, + <>, + <>, + unfiltered_pixel_bytes) + do + nearest_byte = predictor(left_pixel_byte, above_pixel_byte, upper_left_pixel_byte) + unfiltered_byte = <> + unfilter_pixel( + filtered_pixel_rest, + left_pixel_rest, + above_pixel_rest, + upper_left_pixel_rest, + [unfiltered_byte | unfiltered_pixel_bytes] + ) + end + + @doc """ + The Paeth prediction is calculated as `left + above - upper_left`. + This function returns the value nearest to the Paeth prediction, breaking ties + in the order of left, above, upper_left. + + For more information, see the PNG documentation for the [Paeth filter type] + (http://www.w3.org/TR/PNG-Filters.html#Filter-type-4-Paeth) + + ## Example + + iex> Imagineer.Image.PNG.Filter.Basic.Paeth.predictor(37, 84, 1) + 84 + + iex> Imagineer.Image.PNG.Filter.Basic.Paeth.predictor(118, 128, 125) + 118 + + iex> Imagineer.Image.PNG.Filter.Basic.Paeth.predictor(37, 84, 61) + 61 + """ + def predictor(left, above, upper_left) do + prediction = left + above - upper_left + nearest_to_prediction(prediction, left, above, upper_left) + end + + defp nearest_to_prediction(prediction, left, above, upper_left) + when abs(prediction - left) <= abs(prediction - above) + and abs(prediction - left) <= abs(prediction - upper_left) + do + left + end + + defp nearest_to_prediction(prediction, _left, above, upper_left) + when abs(prediction - above) <= abs(prediction - upper_left) + do + above + end + + defp nearest_to_prediction(_prediction, _left, _above, upper_left) do + upper_left + end +end + diff --git a/lib/imagineer/image/png/filter/basic/sub.ex b/lib/imagineer/image/png/filter/basic/sub.ex new file mode 100644 index 0000000..ebd5e75 --- /dev/null +++ b/lib/imagineer/image/png/filter/basic/sub.ex @@ -0,0 +1,63 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.Sub do + import Imagineer.Image.PNG.Helpers, only: [ null_binary: 1 ] + @moduledoc """ + The Sub filter transmits the difference between each byte and the value of the + corresponding byte of the prior pixel. + """ + + @doc """ + Takes in the uncompressed binary for a sub-filtered row of pixels plus the + number of bytes per pixel and returns the a binary of the row as + unfiltered pixel data. + + For more information, see the PNG Filter [documentation for the Sub filter type + ](http://www.w3.org/TR/PNG-Filters.html#Filter-type-1-Sub). + + ## Example + + iex> filtered = <<127, 138, 255, 20, 21, 107>> + iex> Imagineer.Image.PNG.Filter.Basic.Sub.unfilter(filtered, 3) + <<127, 138, 255, 147, 159, 106>> + + iex> filtered = <<1, 77, 16, 234, 234, 154>> + iex> Imagineer.Image.PNG.Filter.Basic.Sub.unfilter(filtered, 3) + <<1, 77, 16, 235, 55, 170>> + + iex> filtered = <<1, 77, 16, 234, 234, 154>> + iex> Imagineer.Image.PNG.Filter.Basic.Sub.unfilter(filtered, 2) + <<1, 77, 17, 55, 250, 132>> + """ + def unfilter(row, bytes_per_pixel) do + # the pixel data before the first pixel is assumed to be all bagels + ghost_prior_pixel = null_binary(bytes_per_pixel) + unfilter(row, ghost_prior_pixel, bytes_per_pixel, []) + |> Enum.join + end + + # In the base case, we'll have a reversed list of lists, each of which + # contains a the unfiltered bytes for a pixel. Flatten them + defp unfilter(<<>>, _prior_pixel, _bytes_per_pixel, unfiltered_pixels) do + List.flatten Enum.reverse unfiltered_pixels + end + + defp unfilter(row, prior_pixel, bytes_per_pixel, unfiltered_pixels) do + <> = row + unfiltered_pixel = unfilter_pixel(prior_pixel, pixel, []) + unfilter(rest, pixel, bytes_per_pixel, [ unfiltered_pixel | unfiltered_pixels]) + end + + # In the base case, we'll have a reversed list of a bunch of unfiltered bytes + defp unfilter_pixel(<<>>, <<>>, unfiltered_bytes) do + Enum.reverse(unfiltered_bytes) + end + + # Adds the corresponding byte values of the current pixel and the previous one + defp unfilter_pixel( + <>, + <>, + unfiltered_bytes) + do + unfiltered_byte = <> + unfilter_pixel(rest_prior, rest_pixel, [unfiltered_byte | unfiltered_bytes]) + end +end diff --git a/lib/imagineer/image/png/filter/basic/up.ex b/lib/imagineer/image/png/filter/basic/up.ex new file mode 100644 index 0000000..dec17cc --- /dev/null +++ b/lib/imagineer/image/png/filter/basic/up.ex @@ -0,0 +1,48 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.Up do + @moduledoc """ + The Up filter transmits the difference between each byte and the value of the + corresponding byte of the same pixel in the row above it. + """ + + @doc """ + Takes in the uncompressed binary for an up-filtered row of pixels and the + unfiltered binary of the preceding row. It returns the a binary of the row + as unfiltered pixel data. + + For more information, see the PNG Filter [documentation for the Up filter type + ](http://www.w3.org/TR/PNG-Filters.html#Filter-type-2-Up). + + ## Example + + iex> filtered_row = <<127, 138, 255, 20, 21, 107>> + iex> prior_unfiltered_row = <<1, 77, 16, 235, 55, 170>> + iex> Imagineer.Image.PNG.Filter.Basic.Up.unfilter(filtered_row, prior_unfiltered_row) + <<128, 215, 15, 255, 76, 21>> + + When the row being unfiltered is the first row, we pass in a binary of equal + length that is all null bytes (`<<0>>`.) + + iex> filtered = <<1, 77, 16, 234, 234, 154>> + iex> prior_unfiltered_row = <<0, 0, 0, 0, 0, 0>> + iex> Imagineer.Image.PNG.Filter.Basic.Up.unfilter(filtered, prior_unfiltered_row) + <<1, 77, 16, 234, 234, 154>> + """ + def unfilter(row, prior_row) do + unfilter(row, prior_row, []) + |> Enum.join + end + + # In the base case, we'll have a reversed list of a bunch of unfiltered bytes + defp unfilter(<<>>, <<>>, unfiltered_bytes) do + Enum.reverse(unfiltered_bytes) + end + + defp unfilter( + <>, + <>, + unfiltered_pixels) + do + unfiltered_byte = <> + unfilter(row_rest, prior_row_rest, [ unfiltered_byte | unfiltered_pixels]) + end +end diff --git a/lib/imagineer/image/png/helpers.ex b/lib/imagineer/image/png/helpers.ex new file mode 100644 index 0000000..8791781 --- /dev/null +++ b/lib/imagineer/image/png/helpers.ex @@ -0,0 +1,98 @@ +defmodule Imagineer.Image.PNG.Helpers do + + @doc """ + Given a PNG's color type and bit depth, returns its color format + """ + def color_format(0, 1) , do: :grayscale1 + def color_format(0, 2) , do: :grayscale2 + def color_format(0, 4) , do: :grayscale4 + def color_format(0, 8) , do: :grayscale8 + def color_format(0, 16), do: :grayscale16 + def color_format(2, 8) , do: :rgb8 + def color_format(2, 16), do: :rgb16 + def color_format(3, 1) , do: :palette1 + def color_format(3, 2) , do: :palette2 + def color_format(3, 4) , do: :palette4 + def color_format(3, 8) , do: :palette8 + def color_format(4, 8) , do: :grayscale_alpha8 + def color_format(4, 16), do: :grayscale_alpha16 + def color_format(6, 8) , do: :rgb_alpha8 + def color_format(6, 16), do: :rgb_alpha16 + + @doc """ + Given a color format and the width of an image, tells us how many bytes are + are present per scanline (a row of pixels). + """ + def bytes_per_row(:grayscale1, width), do: div(width, 8) + def bytes_per_row(:grayscale2, width), do: div(width, 4) + def bytes_per_row(:grayscale4, width), do: div(width, 2) + def bytes_per_row(:grayscale8, width), do: width + def bytes_per_row(:grayscale16, width), do: width * 2 + def bytes_per_row(:rgb8, width), do: width * 3 + def bytes_per_row(:rgb16, width), do: width * 6 + def bytes_per_row(:palette1, width), do: div(width, 8) + def bytes_per_row(:palette2, width), do: div(width, 4) + def bytes_per_row(:palette4, width), do: div(width, 2) + def bytes_per_row(:palette8, width), do: width + def bytes_per_row(:grayscale_alpha8, width), do: width * 2 + def bytes_per_row(:grayscale_alpha16, width), do: width * 4 + def bytes_per_row(:rgb_alpha8, width), do: width * 4 + def bytes_per_row(:rgb_alpha16, width), do: width * 8 + + @doc """ + Given a color format, tells us how many bytes are needed to store a pixel + """ + def bytes_per_pixel(:grayscale1), do: 1 + def bytes_per_pixel(:grayscale2), do: 1 + def bytes_per_pixel(:grayscale4), do: 1 + def bytes_per_pixel(:grayscale8), do: 1 + def bytes_per_pixel(:grayscale16), do: 2 + def bytes_per_pixel(:rgb8), do: 3 + def bytes_per_pixel(:rgb16), do: 6 + def bytes_per_pixel(:palette1), do: 1 + def bytes_per_pixel(:palette2), do: 1 + def bytes_per_pixel(:palette4), do: 1 + def bytes_per_pixel(:palette8), do: 1 + def bytes_per_pixel(:grayscale_alpha8), do: 2 + def bytes_per_pixel(:grayscale_alpha16), do: 4 + def bytes_per_pixel(:rgb_alpha8), do: 4 + def bytes_per_pixel(:rgb_alpha16), do: 8 + + @doc """ + Returns the number of channels for a given `color_format`. For example, + `:rgb8` and `:rbg16` have 3 channels: one for Red, Green, and Blue. + `:rgb_alpha8` and `:rgb_alpha16` each have 4 channels: one for Red, Green, + Blue, and the alpha (transparency) channel. + """ + def channels_per_pixel(:palette1), do: 1 + def channels_per_pixel(:palette2), do: 1 + def channels_per_pixel(:palette4), do: 1 + def channels_per_pixel(:palette8), do: 1 + def channels_per_pixel(:grayscale1), do: 1 + def channels_per_pixel(:grayscale2), do: 1 + def channels_per_pixel(:grayscale4), do: 1 + def channels_per_pixel(:grayscale8), do: 1 + def channels_per_pixel(:grayscale16), do: 1 + def channels_per_pixel(:grayscale_alpha8), do: 2 + def channels_per_pixel(:grayscale_alpha16), do: 2 + def channels_per_pixel(:rgb8), do: 3 + def channels_per_pixel(:rgb16), do: 3 + def channels_per_pixel(:rgb_alpha8), do: 4 + def channels_per_pixel(:rgb_alpha16), do: 4 + + + @doc """ + Returns a binary consisting of `length` null (`<<0>>`) bytes + """ + def null_binary(length) when length >= 0 do + null_binary(<<>>, length) + end + + defp null_binary(null_bytes, 0) do + null_bytes + end + + defp null_binary(null_bytes, length) do + null_binary(null_bytes <> <<0>>, length - 1) + end +end diff --git a/lib/imagineer/image/png/pixels.ex b/lib/imagineer/image/png/pixels.ex new file mode 100644 index 0000000..d7c2b42 --- /dev/null +++ b/lib/imagineer/image/png/pixels.ex @@ -0,0 +1,87 @@ +defmodule Imagineer.Image.PNG.Pixels do + alias Imagineer.Image.PNG + import Imagineer.Image.PNG.Helpers, only: [channels_per_pixel: 1] + + def load_row do + + end + + @doc """ + Extracts the pixels from all of the unfiltered rows. Sets the `pixels` field + on the image and returns it. + + ## Example + iex> alias Imagineer.Image.PNG + iex> image = %PNG{ + ...> color_format: :rgb8, + ...> bit_depth: 8, + ...> unfiltered_rows: [ + ...> {0, <<127, 138, 255, 147, 159, 106>>}, + ...> {1, <<233, 1, 77, 78, 191, 144>>}, + ...> {2, <<234, 78, 93, 56, 169, 42>>}, + ...> {3, <<184, 162, 144, 6, 26, 96>>}, + ...> {4, <<32, 206, 231, 39, 117, 76>>} + ...> ] + ...> } + iex> PNG.Pixels.extract(image).pixels + [ + [ {127, 138, 255}, {147, 159, 106} ], + [ {233, 1, 77}, {78, 191, 144} ], + [ {234, 78, 93}, {56, 169, 42} ], + [ {184, 162, 144}, {6, 26, 96} ], + [ {32, 206, 231}, {39, 117, 76} ] + ] + + """ + def extract(%PNG{ + unfiltered_rows: unfiltered_rows, + color_format: color_format, + bit_depth: bit_depth + }=image) + do + pixels = extract_pixels(unfiltered_rows, channels_per_pixel(color_format), bit_depth, []) + Map.put(image, :pixels, pixels) + end + + defp extract_pixels([], _channels_per_pixel, _bit_depth, pixel_rows) do + Enum.reverse(pixel_rows) + end + + defp extract_pixels([{_row_index, row} | unfiltered_rows], channels_per_pixel, bit_depth, pixel_rows) do + pixel_row = extract_pixels_from_row(row, channels_per_pixel, bit_depth) + extract_pixels(unfiltered_rows, channels_per_pixel, bit_depth, [pixel_row | pixel_rows]) + end + + defp extract_pixels_from_row(row, channels_per_pixel, bit_depth) do + extract_pixels_from_row(row, channels_per_pixel, bit_depth, []) + end + + # In the base case, we have pulled everything from the row and are left with + # a reversed list of pixels + defp extract_pixels_from_row(<<>>, _channels_per_pixel, _bit_depth, pixels) do + Enum.reverse pixels + end + + defp extract_pixels_from_row(row, channels_per_pixel, bit_depth, pixels) do + pixel_size = channels_per_pixel * bit_depth + <> = row + pixel = extract_pixel(pixel_bits, bit_depth, channels_per_pixel) + extract_pixels_from_row(rest_of_row, channels_per_pixel, bit_depth, [pixel | pixels]) + end + + defp extract_pixel(pixel_bits, bit_depth, channels_per_pixel) do + extract_pixel(pixel_bits, bit_depth, [], channels_per_pixel) + end + + # In the base case, we have no more channels to parse and we are done! + defp extract_pixel(<<>>, _bit_depth, channel_list, 0) do + List.to_tuple Enum.reverse channel_list + end + + defp extract_pixel(pixel_bits, bit_depth, channel_list, channels) do + remaining_channels = channels - 1 + rest_size = bit_depth * remaining_channels + <> = pixel_bits + extract_pixel(rest, bit_depth, [channel | channel_list], remaining_channels) + end +end diff --git a/mix.exs b/mix.exs index 78989a5..6bcf674 100644 --- a/mix.exs +++ b/mix.exs @@ -2,29 +2,49 @@ defmodule Imagineer.Mixfile do use Mix.Project def project do - [app: :imagineer, - version: "0.0.1", - elixir: "~> 1.0", - deps: deps] + [ + app: :imagineer, + version: "0.2.0-dev", + elixir: "~> 1.0", + deps: deps, + source_url: "https://github.com/SenecaSystems/imagineer", + description: description, + package: package + ] end - # Configuration for the OTP application - # - # Type `mix help compile.app` for more information def application do [applications: [:logger]] end - # Dependencies can be Hex packages: - # - # {:mydep, "~> 0.3.0"} - # - # Or git/path repositories: - # - # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} - # - # Type `mix help deps` for more examples and options defp deps do [] end + + defp description do + """ + Image processing in Elixir + """ + end + + defp package do + [ + contributors: contributors, + links: links, + licenses: ["MIT"] + ] + end + + defp contributors do + [ + "Chris Maddox" + ] + end + + defp links do + %{ + github: "https://github.com/SenecaSystems/imagineer" + } + end + end diff --git a/test/imagineer/image/png/compression/zlib_test.exs b/test/imagineer/image/png/compression/zlib_test.exs new file mode 100644 index 0000000..28aea71 --- /dev/null +++ b/test/imagineer/image/png/compression/zlib_test.exs @@ -0,0 +1,4 @@ +defmodule Imagineer.Image.PNG.Compression.ZlibTest do + use ExUnit.Case, async: true + doctest Imagineer.Image.PNG.Compression.Zlib +end diff --git a/test/imagineer/image/png/data_content_test.exs b/test/imagineer/image/png/data_content_test.exs new file mode 100644 index 0000000..cf40e1e --- /dev/null +++ b/test/imagineer/image/png/data_content_test.exs @@ -0,0 +1,4 @@ +defmodule Imagineer.Image.PNG.DataContentTest do + use ExUnit.Case, async: true + doctest Imagineer.Image.PNG.DataContent +end diff --git a/test/imagineer/image/png/filter/basic/average_text.exs b/test/imagineer/image/png/filter/basic/average_text.exs new file mode 100644 index 0000000..a3ff663 --- /dev/null +++ b/test/imagineer/image/png/filter/basic/average_text.exs @@ -0,0 +1,4 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.AverageTest do + use ExUnit.Case, async: true + doctest Imagineer.Image.PNG.Filter.Basic.Average +end diff --git a/test/imagineer/image/png/filter/basic/paeth_test.exs b/test/imagineer/image/png/filter/basic/paeth_test.exs new file mode 100644 index 0000000..f30b12d --- /dev/null +++ b/test/imagineer/image/png/filter/basic/paeth_test.exs @@ -0,0 +1,4 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.PaethTest do + use ExUnit.Case, async: true + doctest Imagineer.Image.PNG.Filter.Basic.Paeth +end diff --git a/test/imagineer/image/png/filter/basic/sub_test.exs b/test/imagineer/image/png/filter/basic/sub_test.exs new file mode 100644 index 0000000..57bc3a1 --- /dev/null +++ b/test/imagineer/image/png/filter/basic/sub_test.exs @@ -0,0 +1,4 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.SubTest do + use ExUnit.Case, async: true + doctest Imagineer.Image.PNG.Filter.Basic.Sub +end diff --git a/test/imagineer/image/png/filter/basic/up_test.exs b/test/imagineer/image/png/filter/basic/up_test.exs new file mode 100644 index 0000000..4d11a79 --- /dev/null +++ b/test/imagineer/image/png/filter/basic/up_test.exs @@ -0,0 +1,4 @@ +defmodule Imagineer.Image.PNG.Filter.Basic.UpTest do + use ExUnit.Case, async: true + doctest Imagineer.Image.PNG.Filter.Basic.Up +end diff --git a/test/imagineer/image/png/filter/basic_test.exs b/test/imagineer/image/png/filter/basic_test.exs new file mode 100644 index 0000000..a2a05dc --- /dev/null +++ b/test/imagineer/image/png/filter/basic_test.exs @@ -0,0 +1,33 @@ +defmodule Imagineer.Image.PNG.Filter.BasicTest do + use ExUnit.Case, async: true + alias Imagineer.Image.PNG + alias PNG.Filter.Basic, as: BasicFilter + + test "unfiltering with no filter" do + scanlines = [<<0, 127, 138, 255, 20, 21, 107>>, <<0, 1, 77, 16, 234, 234, 154>>] + unfiltered_lines = %PNG{scanlines: scanlines, color_format: :rgb8, width: 2} + |> BasicFilter.unfilter() + assert unfiltered_lines == + [{0, <<127, 138, 255, 20, 21, 107>>}, {1, <<1, 77, 16, 234, 234, 154>>}] + + end + + test "unfiltering with all kinds of filters!" do + scanlines = [ + <<1, 127, 138, 255, 20, 21, 107>>, # Sub filter + <<0, 233, 1, 77, 78, 191, 144>>, # None filter + <<2, 1, 77, 16, 234, 234, 154>>, # Up filter + <<3, 67, 123, 98, 142, 117, 3>>, # Average filter + <<4, 104, 44, 87, 33, 91, 188>> # Paeth filter + ] + unfiltered_lines = BasicFilter.unfilter(%PNG{scanlines: scanlines, color_format: :rgb8, width: 2}) + assert unfiltered_lines == + [ + {0, <<127, 138, 255, 147, 159, 106>>}, + {1, <<233, 1, 77, 78, 191, 144>>}, + {2, <<234, 78, 93, 56, 169, 42>>}, + {3, <<184, 162, 144, 6, 26, 96>>}, + {4, <<32, 206, 231, 39, 117, 76>>} + ] + end +end diff --git a/test/imagineer/image/png/pixels_test.exs b/test/imagineer/image/png/pixels_test.exs new file mode 100644 index 0000000..2791cd8 --- /dev/null +++ b/test/imagineer/image/png/pixels_test.exs @@ -0,0 +1,4 @@ +defmodule Imagineer.Image.PNG.PixelsTest do + use ExUnit.Case, async: true + doctest Imagineer.Image.PNG.Pixels +end diff --git a/test/imagineer/image_process_test.exs b/test/imagineer/image_process_test.exs index 99c7dba..b3c4701 100644 --- a/test/imagineer/image_process_test.exs +++ b/test/imagineer/image_process_test.exs @@ -1,14 +1,11 @@ defmodule Imagineer.ImageProcessTest do use ExUnit.Case, async: true - alias Imagineer.Image # Until ExUnit has contexts, we don't want to load all images on every `setup` test "it parses the alpaca" do - image = %Image{uri: "./test/support/images/alpaca.png"} |> - Image.load() |> - Image.process() + {:ok, image} = Imagineer.load("./test/support/images/png/alpaca.png") - {:ok, raw_file} = File.read(image.uri) + {:ok, raw_file} = File.read("./test/support/images/png/alpaca.png") assert raw_file == image.raw, "it should set the file's contents into `raw`" assert image.format == :png, "it should set the image format" @@ -20,26 +17,30 @@ defmodule Imagineer.ImageProcessTest do # It should set the color format, color type, and bit_depth assert image.bit_depth == 8, "it should set the bit depth" assert image.color_format == :rgb8, "it should set the color format" - assert image.attributes.color_type == 2, "it should set the color type" + assert image.color_type == 2, "it should set the color type" assert image.attributes.pixel_dimensions == {5669, 5669, :meter}, "it should set the pixel dimensions" + # it should parse out rows of pixels equal to the height + assert length(image.pixels) == image.height + + # each row of pixel should be equal to the width in length + Enum.each(image.pixels, fn (pixel_row) -> assert length(pixel_row) == image.width end) + xmp_text_chunk = "\n \n \n 96\n 96\n \n \n\n" assert image.attributes[:"XML:com.adobe.xmp"] == xmp_text_chunk, "it should pull arbitrary text chunks into attributes" end test "it parses the baby octopus" do - image = %Image{uri: "./test/support/images/baby_octopus.png"} |> - Image.load() |> - Image.process() + {:ok, image} = Imagineer.load("./test/support/images/png/baby_octopus.png") - {:ok, raw_file} = File.read(image.uri) + {:ok, raw_file} = File.read("./test/support/images/png/baby_octopus.png") assert raw_file == image.raw, "it should set the file's contents into `raw`" end -# Image: test/support/images/black.jpg +# Image: test/support/images/jpg/black.jpg # Format: JPEG (Joint Photographic Experts Group JFIF format) # Mime type: image/jpeg # Class: DirectClass @@ -92,7 +93,7 @@ defmodule Imagineer.ImageProcessTest do # jpeg:sampling-factor: 2x1,1x1,1x1 # signature: 3ae28ca36ded757756bbc483c35afe741edcf15b9abaaa5695c6a80640a08702 # Artifacts: -# filename: test/support/images/black.jpg +# filename: test/support/images/jpg/black.jpg # verbose: true # Tainted: True # Filesize: 1.24KB @@ -103,9 +104,7 @@ defmodule Imagineer.ImageProcessTest do # Version: ImageMagick 6.8.9-8 Q16 x86_64 2014-12-22 http://www.imagemagick.org test "it can parse a jpg" do - image = %Imagineer.Image{uri: "./test/support/images/black.jpg"} |> - Imagineer.Image.load() |> - Imagineer.Image.process() + {:ok, image} = Imagineer.load("./test/support/images/jpg/black.jpg") assert image.format == :jpg end end diff --git a/test/imagineer_test.exs b/test/imagineer_test.exs index 702659b..7c0fe21 100644 --- a/test/imagineer_test.exs +++ b/test/imagineer_test.exs @@ -1,5 +1,15 @@ defmodule ImagineerTest do use ExUnit.Case + test "loading a non-existent file" do + uri = "./404.png" + error_message = "Could not open #{uri} because the file does not exist." + {:error, ^error_message} = Imagineer.load(uri) + end + test "loading a file of unknown type" do + uri = "./test/support/images/gif/alpaca.gif" + error_message = "Unknown or unsupported image format." + {:error, ^error_message} = Imagineer.load(uri) + end end diff --git a/test/support/images/gif/alpaca.gif b/test/support/images/gif/alpaca.gif new file mode 100644 index 0000000..fdabc28 Binary files /dev/null and b/test/support/images/gif/alpaca.gif differ diff --git a/test/support/images/black.jpg b/test/support/images/jpg/black.jpg similarity index 100% rename from test/support/images/black.jpg rename to test/support/images/jpg/black.jpg diff --git a/test/support/images/drowning_girl.jpg b/test/support/images/jpg/drowning_girl.jpg similarity index 100% rename from test/support/images/drowning_girl.jpg rename to test/support/images/jpg/drowning_girl.jpg diff --git a/test/support/images/alpaca.png b/test/support/images/png/alpaca.png similarity index 100% rename from test/support/images/alpaca.png rename to test/support/images/png/alpaca.png diff --git a/test/support/images/baby_octopus.png b/test/support/images/png/baby_octopus.png similarity index 100% rename from test/support/images/baby_octopus.png rename to test/support/images/png/baby_octopus.png diff --git a/test/support/images/png/seneca-logo.png b/test/support/images/png/seneca-logo.png new file mode 100644 index 0000000..cb75300 Binary files /dev/null and b/test/support/images/png/seneca-logo.png differ