From bfa244c8fa87827fae286e474fa015b1c214ea7f Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Mon, 19 May 2025 14:06:37 +0300 Subject: [PATCH] add ico and bmp support --- lib/load_bmp.rb | 170 ++++++++++++++ lib/load_ico.rb | 209 ++++++++++++++++++ lib/submissions/generate_audit_trail.rb | 2 +- .../generate_result_attachments.rb | 29 ++- 4 files changed, 403 insertions(+), 7 deletions(-) create mode 100644 lib/load_bmp.rb create mode 100644 lib/load_ico.rb diff --git a/lib/load_bmp.rb b/lib/load_bmp.rb new file mode 100644 index 00000000..b5ea074a --- /dev/null +++ b/lib/load_bmp.rb @@ -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 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 diff --git a/lib/load_ico.rb b/lib/load_ico.rb new file mode 100644 index 00000000..2c48d766 --- /dev/null +++ b/lib/load_ico.rb @@ -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= 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= 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 diff --git a/lib/submissions/generate_audit_trail.rb b/lib/submissions/generate_audit_trail.rb index ecfe9b84..32622049 100644 --- a/lib/submissions/generate_audit_trail.rb +++ b/lib/submissions/generate_audit_trail.rb @@ -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? diff --git a/lib/submissions/generate_result_attachments.rb b/lib/submissions/generate_result_attachments.rb index 0a5da813..de8e055f 100644 --- a/lib/submissions/generate_result_attachments.rb +++ b/lib/submissions/generate_result_attachments.rb @@ -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