diff --git a/README.md b/README.md index b5cc351..c0543dd 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ But the image is not locally stored - it's on another asset server, or in the cl You don't want to download the entire image to your app server - it could be many tens of kilobytes, or even megabytes just to get this information. For most common image types (GIF, PNG, BMP etc.), the size of the image is simply stored at the start of the file. For JPEG files it's a little bit more complex, but even so you do not need to fetch much of the image to find the size. -FastImage does this minimal fetch for image types GIF, JPEG, PNG, TIFF, BMP, ICO, CUR, PSD, SVG and WEBP. And it doesn't rely on installing external libraries such as RMagick (which relies on ImageMagick or GraphicsMagick) or ImageScience (which relies on FreeImage). +FastImage does this minimal fetch for image types GIF, JPEG, PNG, TIFF, BMP, ICO, CUR, PSD, SVG, WEBP and JXL. And it doesn't rely on installing external libraries such as RMagick (which relies on ImageMagick or GraphicsMagick) or ImageScience (which relies on FreeImage). You only need supply the uri, and FastImage will do the rest. @@ -196,6 +196,10 @@ ruby test/test.rb - [Android by qstumn](https://github.com/qstumn/FastImageSize) - [Flutter by ky1vstar](https://github.com/ky1vstar/fastimage.dart) +### Also of interest +- [C++ by xiaozhuai](https://github.com/xiaozhuai/imageinfo) +- [Rust by xiaozhuai](https://github.com/xiaozhuai/imageinfo-rs) + ## Licence MIT, see file "MIT-LICENSE" diff --git a/lib/fastimage.rb b/lib/fastimage.rb index 7b097d4..f83514f 100644 --- a/lib/fastimage.rb +++ b/lib/fastimage.rb @@ -9,7 +9,7 @@ # No external libraries such as ImageMagick are used here, this is a very lightweight solution to # finding image information. # -# FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, PSD, SVG and WEBP files. +# FastImage knows about GIF, JPEG, BMP, TIFF, ICO, CUR, PNG, PSD, SVG, WEBP and JXL files. # # FastImage can also read files from the local filesystem by supplying the path instead of a uri. # In this case FastImage reads the file in chunks of 256 bytes until @@ -551,6 +551,8 @@ def parse_type end when '8B' :psd + when "\xFF\x0A".b + :jxl when "\0\0" case @stream.peek(3).bytes.to_a.last when 0 @@ -564,6 +566,10 @@ def parse_type :heic when "ftypmif1" :heif + else + if @stream.peek(7)[4..-1] == 'JXL' + :jxl + end end # ico has either a 1 (for ico format) or 2 (for cursor) at offset 3 when 1 then :ico @@ -657,6 +663,8 @@ def read_boxes!(max_read_bytes = nil) handle_ispe_box(box_size, index) when "mdat" @stream.skip(box_size) + when "jxlc" + handle_jxlc_box(box_size) else @stream.skip(box_size) end @@ -733,6 +741,11 @@ def handle_meta_box(box_size) throw :finish end + def handle_jxlc_box(box_size) + @final_size = JXL.new(@stream).read_size_header + throw :finish + end + def read_box_header! size = read_uint32! type = @stream.read(4) @@ -772,6 +785,75 @@ def parse_size_for_heif bmff.width_and_height end + class JXL + LENGTHS = [9, 13, 18, 30] + MULTIPLIERS = [1, 1.2, Rational(4, 3), 1.5, Rational(16, 9), 1.25, 2] + + def initialize(stream) + @stream = stream + @bit_counter = 0 + end + + def read_size_header + @words = @stream.read(6)[2..5].unpack('vv') + + # small mode allows for values <= 256 that are divisible by 8 + small = get_bits(1) + if small == 1 + y = (get_bits(5) + 1) * 8 + x = x_from_ratio(y) + if !x + x = (get_bits(5) + 1) * 8 + end + return [x, y] + end + + len = LENGTHS[get_bits(2)] + y = get_bits(len) + 1 + x = x_from_ratio(y) + if !x + len = LENGTHS[get_bits(2)] + x = get_bits(len) + 1 + end + [x, y] + end + + def get_bits(size) + if @words.size < (@bit_counter + size) / 16 + 1 + @words += @stream.read(4).unpack('vv') + end + + dest_pos = 0 + dest = 0 + size.times do + word = @bit_counter / 16 + source_pos = @bit_counter % 16 + dest |= ((@words[word] & (1 << source_pos)) > 0 ? 1 : 0) << dest_pos + dest_pos += 1 + @bit_counter += 1 + end + dest + end + + def x_from_ratio(y) + ratio = get_bits(3) + if ratio == 0 + return nil + else + return (y * MULTIPLIERS[ratio - 1]).to_i + end + end + end + + def parse_size_for_jxl + if @stream.peek(2) == "\xFF\x0A".b + JXL.new(@stream).read_size_header + else + bmff = IsoBmff.new(@stream) + bmff.width_and_height + end + end + class Gif # :nodoc: def initialize(stream) @stream = stream diff --git a/test/fixtures/isobmff.jxl b/test/fixtures/isobmff.jxl new file mode 100644 index 0000000..cf988c1 Binary files /dev/null and b/test/fixtures/isobmff.jxl differ diff --git a/test/fixtures/naked.jxl b/test/fixtures/naked.jxl new file mode 100644 index 0000000..7cf0a8d Binary files /dev/null and b/test/fixtures/naked.jxl differ diff --git a/test/test.rb b/test/test.rb index 2063c94..9de064c 100644 --- a/test/test.rb +++ b/test/test.rb @@ -58,6 +58,8 @@ "avif/fox.avif" => [:avif, [1204, 799]], "avif/kimono.avif" => [:avif, [722, 1024]], "avif/red_green_flash.avif" => [:avif, [256, 256]], + "isobmff.jxl" => [:jxl, [1280,1600]], + "naked.jxl" => [:jxl, [1000,1000]], } BadFixtures = [