defmodule BMP do
alias BMP.{BMPHeader, DIBHeader}
@moduledoc """
Elixir library implementing `BMP` struct allowing to interact with bitmap images.
This library adds new type called `t:bmp/0` and several functions to work with BMP files.
## Examples
iex(1)> img = BMP.new({1920, 1080}, 24, "#F5ABB9")
BMP header:
signature: BM | 42 4D | 2
file size: 5.93 MiB | 36 EC 5E 00 | 4
reserved | 00 00 00 00 | 4
data offset: 54 B | 36 00 00 00 | 4
DIB header:
header size: 40 B | 28 00 00 00 | 4
image size: 1920x1080 | 80 07 00 00 38 04 00 00 | 8
planes: 1 | 01 00 | 2
color depth: 24 bit | 18 00 | 2
compression: type 0 | 00 00 00 00 | 4
compressed size: 5.93 MiB | 00 EC 5E 00 | 4
x resolution: 255 px/m | FF 00 00 00 | 4
y resolution: 255 px/m | FF 00 00 00 | 4
used colors: 0 | 00 00 00 00 | 4
important colors: 0 | 00 00 00 00 | 4
color table: 0 B
raster data: B9 AB F5 B9 AB F5 B9 AB F5 B9 AB F5 ... | 6220800
iex(2)> BMP.write_file!(img, "cat.bmp")
:ok
iex(3)> BMP.read_file!("cat.bmp") == img
true
"""
defstruct bmp_header: %BMPHeader{},
dib_header: %DIBHeader{},
color_table: <<>>,
raster_data: <<>>,
name: nil
@typedoc """
Bitmap image type.
"""
@type bmp :: %BMP{}
@typedoc """
Color depth in bits.
"""
@type color_depth :: 1 | 4 | 8 | 16 | 24
@typedoc false
@type bi_rgb :: 0
@typedoc false
@type bi_rle8 :: 1
@typedoc false
@type bi_rle4 :: 2
@typedoc """
BMP compression type.
* BI_RGB - no compression
* BI_RLE8 - 8 bit RLE encoding
* BI_RLE4 - 4 bit RLE encoding
"""
@type compression :: bi_rgb() | bi_rle8() | bi_rle4()
defp num_to_bytes(n, size) do
bytes = :binary.encode_unsigned(n, :little)
current_size = byte_size(bytes)
cond do
current_size < size ->
bytes <> :binary.copy(<<0x00>>, size - current_size)
true ->
bytes
end
end
@doc """
Creates new BMP image filled with specified color.
Takes width and height, color depth and fill color - either hex string or `<<r, g, b>>` binary, returns a `t:bmp/0`.
## Examples
iex(1)> BMP.new {1600, 900}, 24, "#F5ABB9"
BMP header:
signature: BM | 42 4D | 2
file size: 4.12 MiB | 36 EB 41 00 | 4
reserved | 00 00 00 00 | 4
data offset: 54 B | 36 00 00 00 | 4
DIB header:
header size: 40 B | 28 00 00 00 | 4
image size: 1600x900 | 40 06 00 00 84 03 00 00 | 8
planes: 1 | 01 00 | 2
color depth: 24 bit | 18 00 | 2
compression: type 0 | 00 00 00 00 | 4
compressed size: 4.12 MiB | 00 EB 41 00 | 4
x resolution: 255 px/m | FF 00 00 00 | 4
y resolution: 255 px/m | FF 00 00 00 | 4
used colors: 0 | 00 00 00 00 | 4
important colors: 0 | 00 00 00 00 | 4
color table: 0 B
raster data: B9 AB F5 B9 AB F5 B9 AB F5 B9 AB F5 ... | 4320000
iex(2)> BMP.new({1600, 900}, 24, "#F5ABB9") == BMP.new({1600, 900}, 24, <<245, 171, 185>>)
true
"""
@spec new({non_neg_integer(), non_neg_integer()}, color_depth(), String.t()) :: bmp()
def new(size, depth, _fill = <<?\#, r::16, g::16, b::16>>) do
pixel_color =
[<<r::16>>, <<g::16>>, <<b::16>>]
|> Enum.map(&String.to_integer(&1, 16))
|> :binary.list_to_bin()
new(size, depth, pixel_color)
end
def new({width, height}, depth, _fill = <<r::8, g::8, b::8>>) do
row_size = ceil(depth * width / 32) * 4
size = row_size * height + 54
padding = row_size - width * div(depth, 8)
%BMP{
bmp_header: %BMPHeader{
file_size: num_to_bytes(size, 4)
},
dib_header: %DIBHeader{
width: num_to_bytes(width, 4),
height: num_to_bytes(height, 4),
color_depth: num_to_bytes(depth, 2),
compressed_size: num_to_bytes(size - 54, 4)
},
raster_data:
Enum.map(1..height, fn _ ->
:binary.copy(<<b, g, r>>, width) <> <<0::size(8 * padding)>>
end)
|> :binary.list_to_bin()
}
end
@doc """
Reads specified file to `t:bmp/0`.
Takes path to file as its only argument and returns `t:bmp/0` on success, raises `Exceptions.FileReadError` otherwise.
## Examples
iex(1)> BMP.read_file("mew.bmp")
mew.bmp
-------
BMP header:
signature: BM | 42 4D | 2
file size: 3.05 KiB | 36 0C 00 00 | 4
reserved | 00 00 00 00 | 4
data offset: 54 B | 36 00 00 00 | 4
DIB header:
header size: 40 B | 28 00 00 00 | 4
image size: 32x32 | 20 00 00 00 20 00 00 00 | 8
planes: 1 | 01 00 | 2
color depth: 24 bit | 18 00 | 2
compression: type 0 | 00 00 00 00 | 4
compressed size: 3.0 KiB | 00 0C 00 00 | 4
x resolution: 3780 px/m | C4 0E 00 00 | 4
y resolution: 3780 px/m | C4 0E 00 00 | 4
used colors: 0 | 00 00 00 00 | 4
important colors: 0 | 00 00 00 00 | 4
color table: 0 B
raster data: FF FF FF FF FF FF FF FF FF FF FF FF ... | 3072
iex(2)> BMP.read_file("xeon.jpg")
** (Exceptions.FileReadError) error reading file "xeon.jpg": not a BMP file
(bmp 0.1.0) lib/bmp.ex:101: BMP.read_file!/1
iex:2: (file)
"""
@spec read_file!(Path.t()) :: bmp()
def read_file!(path) do
case read_file(path) do
{:ok, bmp} ->
bmp
{:error, :not_a_bmp} ->
raise Exceptions.FileReadError, message: "not a BMP file", path: path
end
end
@doc """
Reads specified file to `t:bmp/0`.
Takes path to file as its only argument and returns `{:ok, bmp}` on success, `{:error, reason}` otherwise.
## Examples
iex(1)> BMP.read_file("mew.bmp")
{:ok,
mew.bmp
-------
BMP header:
signature: BM | 42 4D | 2
file size: 3.05 KiB | 36 0C 00 00 | 4
reserved | 00 00 00 00 | 4
data offset: 54 B | 36 00 00 00 | 4
DIB header:
header size: 40 B | 28 00 00 00 | 4
image size: 32x32 | 20 00 00 00 20 00 00 00 | 8
planes: 1 | 01 00 | 2
color depth: 24 bit | 18 00 | 2
compression: type 0 | 00 00 00 00 | 4
compressed size: 3.0 KiB | 00 0C 00 00 | 4
x resolution: 3780 px/m | C4 0E 00 00 | 4
y resolution: 3780 px/m | C4 0E 00 00 | 4
used colors: 0 | 00 00 00 00 | 4
important colors: 0 | 00 00 00 00 | 4
color table: 0 B
raster data: FF FF FF FF FF FF FF FF FF FF FF FF ... | 3072
}
iex(2)> BMP.read_file("xeon.jpg")
{:error, :not_a_bmp}
"""
@spec read_file(Path.t()) :: {:ok, bmp()} | {:error, atom()}
def read_file(path) do
with file <- File.read!(path),
header <- :binary.part(file, 0, 14),
signature <- :binary.part(header, 0, 2),
info_header <- :binary.part(file, 14, 40) do
if signature == "BM" do
depth =
:binary.part(info_header, 12, 2)
|> :binary.decode_unsigned(:little)
{color_table?, color_table_size?} =
cond do
depth <= 8 ->
colors_number =
:binary.copy(<<0xFF>>, div(depth, 8))
|> :binary.decode_unsigned()
{
:binary.part(file, 54, 4 * colors_number),
4 * colors_number
}
true ->
{<<>>, 0}
end
compressed_size = :binary.part(info_header, 20, 4)
{:ok,
%BMP{
bmp_header: %BMPHeader{
file_size: :binary.part(header, 2, 4)
},
dib_header: %DIBHeader{
width: :binary.part(info_header, 4, 4),
height: :binary.part(info_header, 8, 4),
color_depth: :binary.part(info_header, 14, 2),
compression: :binary.part(info_header, 16, 4),
compressed_size: compressed_size,
x_pixels_per_m: :binary.part(info_header, 24, 4),
y_pixels_per_m: :binary.part(info_header, 28, 4),
used_colors: :binary.part(info_header, 32, 4),
important_colors: :binary.part(info_header, 36, 4)
},
color_table: color_table?,
raster_data:
:binary.part(
file,
54 + color_table_size?,
:binary.decode_unsigned(compressed_size, :little)
),
name: Path.basename(path)
}}
else
{:error, :not_a_bmp}
end
end
end
@doc """
Writes `bmp` to the file `path`.
Returns `:ok` if successful, `{:error, reason}` otherwise.
"""
@spec write_file(bmp(), Path.t()) :: :ok | {:error, File.posix()}
def write_file(bmp, path) do
write_file(&File.write/2, bmp, path)
end
@doc """
Writes `bmp` to the file `path`.
Returns `:ok` if successful, raises `File.Error` exception otherwise.
"""
@spec write_file!(bmp(), Path.t()) :: :ok
def write_file!(bmp, path) do
write_file(&File.write!/2, bmp, path)
end
defp write_file(func, bmp, path) do
func.(
path,
Enum.join([
bmp.bmp_header.signature,
bmp.bmp_header.file_size,
bmp.bmp_header.reserved,
bmp.bmp_header.data_offset,
bmp.dib_header.dib_header_size,
bmp.dib_header.width,
bmp.dib_header.height,
bmp.dib_header.planes,
bmp.dib_header.color_depth,
bmp.dib_header.compression,
bmp.dib_header.compressed_size,
bmp.dib_header.x_pixels_per_m,
bmp.dib_header.y_pixels_per_m,
bmp.dib_header.used_colors,
bmp.dib_header.important_colors,
bmp.color_table,
bmp.raster_data
])
)
end
end