add ico and bmp support

pull/493/head
Pete Matsyburka 5 months ago
parent 8a10852fe2
commit bfa244c8fa

@ -0,0 +1,170 @@
# frozen_string_literal: true
module LoadBmp
module_function
# rubocop:disable Metrics
def call(bmp_bytes)
bmp_bytes = bmp_bytes.b
header_data = parse_bmp_headers(bmp_bytes)
raw_pixel_data_from_file = extract_raw_pixel_data_blob(
bmp_bytes,
header_data[:pixel_data_offset],
header_data[:bmp_stride],
header_data[:height]
)
final_pixel_data = prepare_unpadded_pixel_data_string(
raw_pixel_data_from_file,
header_data[:bpp],
header_data[:width],
header_data[:height],
header_data[:bmp_stride]
)
bands = header_data[:bpp] / 8
unless header_data[:bpp] == 24 || header_data[:bpp] == 32
raise ArgumentError, "Conversion for #{header_data[:bpp]}-bpp BMP not implemented."
end
image = Vips::Image.new_from_memory(final_pixel_data, header_data[:width], header_data[:height], bands, :uchar)
image = image.flip(:vertical) if header_data[:orientation] == -1
image_rgb =
if bands == 3
Vips::Image.bandjoin([image[2], image[1], image[0]])
elsif bands == 4
Vips::Image.bandjoin([image[2], image[1], image[0], image[3]])
else
image
end
if image_rgb.interpretation == :multiband || image_rgb.interpretation == :'b-w'
image_rgb = image_rgb.copy(interpretation: :srgb)
end
image_rgb
end
def parse_bmp_headers(bmp_bytes)
raise ArgumentError, 'BMP data too short for file header (14 bytes).' if bmp_bytes.bytesize < 14
signature, pixel_data_offset = bmp_bytes.unpack('a2@10L<')
raise ArgumentError, "Not a valid BMP file (invalid signature 'BM')." if signature != 'BM'
raise ArgumentError, 'BMP data too short for info header size field (4 bytes).' if bmp_bytes.bytesize < (14 + 4)
info_header_size = bmp_bytes.unpack1('@14L<')
min_expected_info_header_size = 40
if info_header_size < min_expected_info_header_size
raise ArgumentError,
"Unsupported BMP info header size: #{info_header_size}. Expected at least #{min_expected_info_header_size}."
end
header_and_info_header_min_bytes = 14 + min_expected_info_header_size
if bmp_bytes.bytesize < header_and_info_header_min_bytes
raise ArgumentError,
'BMP data too short for essential BITMAPINFOHEADER fields ' \
"(requires #{header_and_info_header_min_bytes} bytes total)."
end
_header_size_check, width, raw_height_from_header, planes, bpp, compression =
bmp_bytes.unpack('@14L<l<l<S<S<L<')
height = 0
orientation = -1
if raw_height_from_header.negative?
height = -raw_height_from_header
orientation = 1
else
height = raw_height_from_header
end
raise ArgumentError, 'BMP width must be positive.' if width <= 0
raise ArgumentError, 'BMP height must be positive.' if height <= 0
if compression != 0
raise ArgumentError,
"Unsupported BMP compression type: #{compression}. Only uncompressed (0) is supported."
end
unless [24, 32].include?(bpp)
raise ArgumentError, "Unsupported BMP bits per pixel: #{bpp}. Only 24-bit and 32-bit are supported."
end
raise ArgumentError, "Unsupported BMP planes: #{planes}. Expected 1." if planes != 1
bytes_per_pixel = bpp / 8
row_size_unpadded = width * bytes_per_pixel
bmp_stride = (row_size_unpadded + 3) & ~3
{
width:,
height:,
bpp:,
pixel_data_offset:,
bmp_stride:,
orientation:
}
end
def extract_raw_pixel_data_blob(bmp_bytes, pixel_data_offset, bmp_stride, height)
expected_pixel_data_size = bmp_stride * height
if pixel_data_offset + expected_pixel_data_size > bmp_bytes.bytesize
actual_available = bmp_bytes.bytesize - pixel_data_offset
actual_available = 0 if actual_available.negative?
raise ArgumentError,
"Pixel data segment (offset #{pixel_data_offset}, expected size #{expected_pixel_data_size}) " \
"exceeds BMP file size (#{bmp_bytes.bytesize}). " \
"Only #{actual_available} bytes available after offset."
end
raw_pixel_data_from_file = bmp_bytes.byteslice(pixel_data_offset, expected_pixel_data_size)
if raw_pixel_data_from_file.nil? || raw_pixel_data_from_file.bytesize < expected_pixel_data_size
raise ArgumentError,
"Extracted pixel data is smaller (#{raw_pixel_data_from_file&.bytesize || 0} bytes) " \
"than expected (#{expected_pixel_data_size} bytes based on stride and height)."
end
raw_pixel_data_from_file
end
def prepare_unpadded_pixel_data_string(raw_pixel_data_from_file, bpp, width, height, bmp_stride)
bytes_per_pixel = bpp / 8
actual_row_width_bytes = width * bytes_per_pixel
unpadded_rows = Array.new(height)
current_offset_in_blob = 0
height.times do |i|
if current_offset_in_blob + actual_row_width_bytes > raw_pixel_data_from_file.bytesize
raise ArgumentError,
"Not enough data in pixel blob for row #{i}. Offset #{current_offset_in_blob}, " \
"row width #{actual_row_width_bytes}, blob size #{raw_pixel_data_from_file.bytesize}"
end
unpadded_row_slice = raw_pixel_data_from_file.byteslice(current_offset_in_blob, actual_row_width_bytes)
if unpadded_row_slice.nil? || unpadded_row_slice.bytesize < actual_row_width_bytes
raise ArgumentError, "Failed to slice a full unpadded row from pixel data blob for row #{i}."
end
unpadded_rows[i] = unpadded_row_slice
current_offset_in_blob += bmp_stride
end
unpadded_rows.join
end
# rubocop:enable Metrics
end

@ -0,0 +1,209 @@
# frozen_string_literal: true
module LoadIco
BI_RGB = 0
module_function
# rubocop:disable Metrics
def call(ico_bytes)
io = StringIO.new(ico_bytes)
_reserved, type, count = io.read(6)&.unpack('S<S<S<')
raise ArgumentError, 'Unable to load' unless type == 1 && count&.positive?
ico_entries_parsed = []
count.times do
entry_bytes = io.read(16)
raise ArgumentError, 'Unable to load' unless entry_bytes && entry_bytes.bytesize == 16
width_byte, height_byte, _num_colors_palette, _rsvd_entry, _planes_icon_entry, bpp_icon_entry,
img_data_size, img_data_offset = entry_bytes.unpack('CCCCS<S<L<L<')
width = width_byte.zero? ? 256 : width_byte
height = height_byte.zero? ? 256 : height_byte
sort_bpp = bpp_icon_entry.zero? ? 32 : bpp_icon_entry
ico_entries_parsed << {
width: width, height: height,
sort_bpp: sort_bpp,
size: img_data_size, offset: img_data_offset
}
end
best_entry = ico_entries_parsed.min_by { |e| [-e[:width] * e[:height], -e[:sort_bpp]] }
raise ArgumentError, 'Unable to load' unless best_entry
io.seek(best_entry[:offset])
image_data_bytes = io.read(best_entry[:size])
raise ArgumentError, 'Unable to load' unless image_data_bytes && image_data_bytes.bytesize == best_entry[:size]
image = load_image_entry(image_data_bytes, best_entry[:width], best_entry[:height])
raise ArgumentError, 'Unable to load' unless image
image
end
def load_image_entry(image_data_bytes, ico_entry_width, ico_entry_height)
dib_io = StringIO.new(image_data_bytes)
dib_header_size_arr = dib_io.read(4)&.unpack('L<')
return nil unless dib_header_size_arr
dib_header_size = dib_header_size_arr.first
return nil unless dib_header_size && dib_header_size >= 40
dib_params_bytes = dib_io.read(36)
return nil unless dib_params_bytes && dib_params_bytes.bytesize == 36
dib_width, dib_actual_height_field, dib_planes, dib_bpp,
dib_compression, _dib_image_size, _xpels, _ypels,
dib_clr_used, _dib_clr_important = dib_params_bytes.unpack('l<l<S<S<L<L<l<l<L<L<')
return nil unless dib_width && dib_actual_height_field && dib_planes && dib_bpp && dib_compression && dib_clr_used
return nil unless dib_width == ico_entry_width
image_pixel_height = ico_entry_height
expected_dib_height_no_mask = image_pixel_height
expected_dib_height_with_mask = image_pixel_height * 2
actual_dib_pixel_rows_abs = dib_actual_height_field.abs
unless actual_dib_pixel_rows_abs == expected_dib_height_no_mask ||
actual_dib_pixel_rows_abs == expected_dib_height_with_mask
return nil
end
return nil unless dib_planes == 1
return nil unless dib_compression == BI_RGB
return nil unless [1, 4, 8, 24, 32].include?(dib_bpp)
has_and_mask = (actual_dib_pixel_rows_abs == expected_dib_height_with_mask) && (dib_bpp < 32)
dib_io.seek(dib_header_size, IO::SEEK_SET)
palette = []
if dib_bpp <= 8
num_palette_entries = dib_clr_used.zero? ? (1 << dib_bpp) : dib_clr_used
num_palette_entries.times do
palette_color_bytes = dib_io.read(4)
return nil unless palette_color_bytes && palette_color_bytes.bytesize == 4
b, g, r, _a_reserved = palette_color_bytes.unpack('CCCC')
palette << [r, g, b, 255]
end
end
xor_mask_data_offset = dib_io.pos
xor_scanline_stride = (((dib_width * dib_bpp) + 31) / 32) * 4
and_mask_data_offset = 0
and_scanline_stride = 0
if has_and_mask
and_mask_data_offset = xor_mask_data_offset + (image_pixel_height * xor_scanline_stride)
and_scanline_stride = (((dib_width * 1) + 31) / 32) * 4
end
flat_rgba_pixels = []
(0...image_pixel_height).each do |y_row|
y_dib_row = image_pixel_height - 1 - y_row
dib_io.seek(xor_mask_data_offset + (y_dib_row * xor_scanline_stride))
xor_scanline_bytes = dib_io.read(xor_scanline_stride)
min_xor_bytes_needed = ((dib_width * dib_bpp) + 7) / 8
return nil unless xor_scanline_bytes && xor_scanline_bytes.bytesize >= min_xor_bytes_needed
and_mask_bits_for_row = []
if has_and_mask
dib_io.seek(and_mask_data_offset + (y_dib_row * and_scanline_stride))
and_mask_scanline_bytes = dib_io.read(and_scanline_stride)
min_and_bytes_needed = ((dib_width * 1) + 7) / 8
return nil unless and_mask_scanline_bytes && and_mask_scanline_bytes.bytesize >= min_and_bytes_needed
(0...dib_width).each do |x_pixel|
byte_index = x_pixel / 8
bit_index_in_byte = 7 - (x_pixel % 8)
byte_val = and_mask_scanline_bytes.getbyte(byte_index)
and_mask_bits_for_row << ((byte_val >> bit_index_in_byte) & 1)
end
end
(0...dib_width).each do |x_pixel|
r = 0
g = 0
b = 0
a = 255
case dib_bpp
when 32
offset = x_pixel * 4
blue = xor_scanline_bytes.getbyte(offset)
green = xor_scanline_bytes.getbyte(offset + 1)
red = xor_scanline_bytes.getbyte(offset + 2)
alpha_val = xor_scanline_bytes.getbyte(offset + 3)
r = red
g = green
b = blue
a = alpha_val
when 24
offset = x_pixel * 3
blue = xor_scanline_bytes.getbyte(offset)
green = xor_scanline_bytes.getbyte(offset + 1)
red = xor_scanline_bytes.getbyte(offset + 2)
r = red
g = green
b = blue
when 8
idx = xor_scanline_bytes.getbyte(x_pixel)
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
when 4
byte_val = xor_scanline_bytes.getbyte(x_pixel / 2)
idx = (x_pixel.even? ? (byte_val >> 4) : (byte_val & 0x0F))
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
when 1
byte_val = xor_scanline_bytes.getbyte(x_pixel / 8)
idx = (byte_val >> (7 - (x_pixel % 8))) & 1
r_p, g_p, b_p, a_p = palette[idx] || [0, 0, 0, 0]
r = r_p
g = g_p
b = b_p
a = a_p
end
if has_and_mask && !and_mask_bits_for_row.empty?
a = and_mask_bits_for_row[x_pixel] == 1 ? 0 : 255
end
flat_rgba_pixels.push(r, g, b, a)
end
end
pixel_data_string = flat_rgba_pixels.pack('C*')
expected_bytes = dib_width * image_pixel_height * 4
return nil unless pixel_data_string.bytesize == expected_bytes && expected_bytes.positive?
Vips::Image.new_from_memory(
pixel_data_string,
dib_width,
image_pixel_height,
4,
:uchar
)
end
# rubocop:enable Metrics
end

@ -309,7 +309,7 @@ module Submissions
image =
begin
Vips::Image.new_from_buffer(attachment.download, '').autorot
Submissions::GenerateResultAttachments.load_vips_image(attachment).autorot
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?

@ -11,6 +11,9 @@ module Submissions
'Helvetica'
end
ICO_REGEXP = %r{\Aimage/(?:x-icon|vnd\.microsoft\.icon)\z}
BMP_REGEXP = %r{\Aimage/(?:bmp|x-bmp|x-ms-bmp)\z}
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
FONT_BOLD_PATH
else
@ -250,9 +253,7 @@ module Submissions
when ->(type) { type == 'signature' && (with_signature_id || field.dig('preferences', 'reason_field_uuid')) }
attachment = submitter.attachments.find { |a| a.uuid == value }
attachments_data_cache[attachment.uuid] ||= attachment.download
image = Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
image = load_vips_image(attachment, attachments_data_cache).autorot
reason_value = submitter.values[field.dig('preferences', 'reason_field_uuid')].presence
@ -360,11 +361,9 @@ module Submissions
when 'image', 'signature', 'initials', 'stamp'
attachment = submitter.attachments.find { |a| a.uuid == value }
attachments_data_cache[attachment.uuid] ||= attachment.download
image =
begin
Vips::Image.new_from_buffer(attachments_data_cache[attachment.uuid], '').autorot
load_vips_image(attachment, attachments_data_cache).autorot
rescue Vips::Error
next unless attachment.content_type.starts_with?('image/')
next if attachment.byte_size.zero?
@ -804,6 +803,24 @@ module Submissions
[]
end
def load_vips_image(attachment, cache = {})
cache[attachment.uuid] ||= attachment.download
data = cache[attachment.uuid]
if ICO_REGEXP.match?(attachment.content_type)
Rollbar.error("Load ICO: #{attachment.uuid}") if defined?(Rollbar)
LoadIco.call(data)
elsif BMP_REGEXP.match?(attachment.content_type)
Rollbar.error("Load BMP: #{attachment.uuid}") if defined?(Rollbar)
LoadBmp.call(data)
else
Vips::Image.new_from_buffer(data, '')
end
end
def h
Rails.application.routes.url_helpers
end

Loading…
Cancel
Save