mirror of https://github.com/docusealco/docuseal
				
				
				
			
						commit
						c6eae66127
					
				| @ -0,0 +1,187 @@ | |||||||
|  | # 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 | ||||||
|  |         image.recomb(band3_recomb) | ||||||
|  |       elsif bands == 4 | ||||||
|  |         image.recomb(band4_recomb) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |     image_rgb = image_rgb.copy(interpretation: :srgb) if image_rgb.interpretation != :srgb | ||||||
|  | 
 | ||||||
|  |     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 | ||||||
|  | 
 | ||||||
|  |   def band3_recomb | ||||||
|  |     @band3_recomb ||= | ||||||
|  |       Vips::Image.new_from_array( | ||||||
|  |         [ | ||||||
|  |           [0, 0, 1], | ||||||
|  |           [0, 1, 0], | ||||||
|  |           [1, 0, 0] | ||||||
|  |         ] | ||||||
|  |       ) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def band4_recomb | ||||||
|  |     @band4_recomb ||= Vips::Image.new_from_array( | ||||||
|  |       [ | ||||||
|  |         [0, 0, 1, 0], | ||||||
|  |         [0, 1, 0, 0], | ||||||
|  |         [1, 0, 0, 0] | ||||||
|  |       ] | ||||||
|  |     ) | ||||||
|  |   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 | ||||||
| @ -0,0 +1,412 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | class Pdfium | ||||||
|  |   extend FFI::Library | ||||||
|  | 
 | ||||||
|  |   LIB_NAME = 'pdfium' | ||||||
|  | 
 | ||||||
|  |   begin | ||||||
|  |     ffi_lib case FFI::Platform::OS | ||||||
|  |             when 'darwin' | ||||||
|  |               [ | ||||||
|  |                 "lib#{LIB_NAME}.dylib", | ||||||
|  |                 '/Applications/LibreOffice.app/Contents/Frameworks/libpdfiumlo.dylib' | ||||||
|  |               ] | ||||||
|  |             else | ||||||
|  |               "lib#{LIB_NAME}.so" | ||||||
|  |             end | ||||||
|  |   rescue LoadError => e | ||||||
|  |     raise "Could not load libpdfium library. Make sure it's installed and in your library path. Error: #{e.message}" | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   typedef :pointer, :FPDF_STRING | ||||||
|  |   typedef :pointer, :FPDF_DOCUMENT | ||||||
|  |   typedef :pointer, :FPDF_PAGE | ||||||
|  |   typedef :pointer, :FPDF_BITMAP | ||||||
|  |   typedef :pointer, :FPDF_FORMHANDLE | ||||||
|  | 
 | ||||||
|  |   MAX_SIZE = 32_767 | ||||||
|  | 
 | ||||||
|  |   FPDF_ANNOT = 0x01 | ||||||
|  |   FPDF_LCD_TEXT = 0x02 | ||||||
|  |   FPDF_NO_NATIVETEXT = 0x04 | ||||||
|  |   FPDF_GRAYSCALE = 0x08 | ||||||
|  |   FPDF_REVERSE_BYTE_ORDER = 0x10 | ||||||
|  |   FPDF_RENDER_LIMITEDIMAGECACHE = 0x200 | ||||||
|  |   FPDF_RENDER_FORCEHALFTONE = 0x400 | ||||||
|  |   FPDF_PRINTING = 0x800 | ||||||
|  | 
 | ||||||
|  |   # rubocop:disable Naming/ClassAndModuleCamelCase | ||||||
|  |   class FPDF_LIBRARY_CONFIG < FFI::Struct | ||||||
|  |     layout :version, :int, | ||||||
|  |            :m_pUserFontPaths, :pointer, | ||||||
|  |            :m_pIsolate, :pointer, | ||||||
|  |            :m_v8EmbedderSlot, :uint, | ||||||
|  |            :m_pPlatform, :pointer, | ||||||
|  |            :m_RendererType, :int | ||||||
|  |   end | ||||||
|  |   # rubocop:enable Naming/ClassAndModuleCamelCase | ||||||
|  | 
 | ||||||
|  |   attach_function :FPDF_InitLibraryWithConfig, [:pointer], :void | ||||||
|  |   attach_function :FPDF_DestroyLibrary, [], :void | ||||||
|  | 
 | ||||||
|  |   attach_function :FPDF_LoadDocument, %i[string FPDF_STRING], :FPDF_DOCUMENT | ||||||
|  |   attach_function :FPDF_LoadMemDocument, %i[pointer int FPDF_STRING], :FPDF_DOCUMENT | ||||||
|  |   attach_function :FPDF_CloseDocument, [:FPDF_DOCUMENT], :void | ||||||
|  |   attach_function :FPDF_GetPageCount, [:FPDF_DOCUMENT], :int | ||||||
|  |   attach_function :FPDF_GetLastError, [], :ulong | ||||||
|  | 
 | ||||||
|  |   attach_function :FPDF_LoadPage, %i[FPDF_DOCUMENT int], :FPDF_PAGE | ||||||
|  |   attach_function :FPDF_ClosePage, [:FPDF_PAGE], :void | ||||||
|  |   attach_function :FPDF_GetPageWidthF, [:FPDF_PAGE], :float | ||||||
|  |   attach_function :FPDF_GetPageHeightF, [:FPDF_PAGE], :float | ||||||
|  | 
 | ||||||
|  |   attach_function :FPDFBitmap_Create, %i[int int int], :FPDF_BITMAP | ||||||
|  |   attach_function :FPDFBitmap_CreateEx, %i[int int int pointer int], :FPDF_BITMAP | ||||||
|  |   attach_function :FPDFBitmap_Destroy, [:FPDF_BITMAP], :void | ||||||
|  |   attach_function :FPDFBitmap_GetBuffer, [:FPDF_BITMAP], :pointer | ||||||
|  |   attach_function :FPDFBitmap_GetWidth, [:FPDF_BITMAP], :int | ||||||
|  |   attach_function :FPDFBitmap_GetHeight, [:FPDF_BITMAP], :int | ||||||
|  |   attach_function :FPDFBitmap_GetStride, [:FPDF_BITMAP], :int | ||||||
|  |   attach_function :FPDFBitmap_FillRect, %i[FPDF_BITMAP int int int int ulong], :void | ||||||
|  | 
 | ||||||
|  |   attach_function :FPDF_RenderPageBitmap, %i[FPDF_BITMAP FPDF_PAGE int int int int int int], :void | ||||||
|  | 
 | ||||||
|  |   typedef :int, :FPDF_BOOL | ||||||
|  |   typedef :pointer, :IPDF_JSPLATFORM | ||||||
|  | 
 | ||||||
|  |   # rubocop:disable Naming/ClassAndModuleCamelCase | ||||||
|  |   class FPDF_FORMFILLINFO_V2 < FFI::Struct | ||||||
|  |     layout :version, :int, | ||||||
|  |            :Release, :pointer, | ||||||
|  |            :FFI_Invalidate, :pointer, | ||||||
|  |            :FFI_OutputSelectedRect, :pointer, | ||||||
|  |            :FFI_SetCursor, :pointer, | ||||||
|  |            :FFI_SetTimer, :pointer, | ||||||
|  |            :FFI_KillTimer, :pointer, | ||||||
|  |            :FFI_GetLocalTime, :pointer, | ||||||
|  |            :FFI_OnChange, :pointer, | ||||||
|  |            :FFI_GetPage, :pointer, | ||||||
|  |            :FFI_GetCurrentPage, :pointer, | ||||||
|  |            :FFI_GetRotation, :pointer, | ||||||
|  |            :FFI_ExecuteNamedAction, :pointer, | ||||||
|  |            :FFI_SetTextFieldFocus, :pointer, | ||||||
|  |            :FFI_DoURIAction, :pointer, | ||||||
|  |            :FFI_DoGoToAction, :pointer, | ||||||
|  |            :m_pJsPlatform, :IPDF_JSPLATFORM, | ||||||
|  |            :xfa_disabled, :FPDF_BOOL, | ||||||
|  |            :FFI_DisplayCaret, :pointer, | ||||||
|  |            :FFI_GetCurrentPageIndex, :pointer, | ||||||
|  |            :FFI_SetCurrentPage, :pointer, | ||||||
|  |            :FFI_GotoURL, :pointer, | ||||||
|  |            :FFI_GetPageViewRect, :pointer, | ||||||
|  |            :FFI_PageEvent, :pointer, | ||||||
|  |            :FFI_PopupMenu, :pointer, | ||||||
|  |            :FFI_OpenFile, :pointer, | ||||||
|  |            :FFI_EmailTo, :pointer, | ||||||
|  |            :FFI_UploadTo, :pointer, | ||||||
|  |            :FFI_GetPlatform, :pointer, | ||||||
|  |            :FFI_GetLanguage, :pointer, | ||||||
|  |            :FFI_DownloadFromURL, :pointer, | ||||||
|  |            :FFI_PostRequestURL, :pointer, | ||||||
|  |            :FFI_PutRequestURL, :pointer, | ||||||
|  |            :FFI_OnFocusChange, :pointer, | ||||||
|  |            :FFI_DoURIActionWithKeyboardModifier, :pointer | ||||||
|  |   end | ||||||
|  |   # rubocop:enable Naming/ClassAndModuleCamelCase | ||||||
|  | 
 | ||||||
|  |   attach_function :FPDFDOC_InitFormFillEnvironment, %i[FPDF_DOCUMENT pointer], :FPDF_FORMHANDLE | ||||||
|  |   attach_function :FPDFDOC_ExitFormFillEnvironment, [:FPDF_FORMHANDLE], :void | ||||||
|  |   attach_function :FPDF_FFLDraw, %i[FPDF_FORMHANDLE FPDF_BITMAP FPDF_PAGE int int int int int int], :void | ||||||
|  | 
 | ||||||
|  |   FPDF_ERR_SUCCESS = 0 | ||||||
|  |   FPDF_ERR_UNKNOWN = 1 | ||||||
|  |   FPDF_ERR_FILE = 2 | ||||||
|  |   FPDF_ERR_FORMAT = 3 | ||||||
|  |   FPDF_ERR_PASSWORD = 4 | ||||||
|  |   FPDF_ERR_SECURITY = 5 | ||||||
|  |   FPDF_ERR_PAGE = 6 | ||||||
|  | 
 | ||||||
|  |   PDFIUM_ERRORS = { | ||||||
|  |     FPDF_ERR_SUCCESS => 'Success', | ||||||
|  |     FPDF_ERR_UNKNOWN => 'Unknown error', | ||||||
|  |     FPDF_ERR_FILE => 'Error open file', | ||||||
|  |     FPDF_ERR_FORMAT => 'Invalid format', | ||||||
|  |     FPDF_ERR_PASSWORD => 'Incorrect password', | ||||||
|  |     FPDF_ERR_SECURITY => 'Security scheme error', | ||||||
|  |     FPDF_ERR_PAGE => 'Page not found' | ||||||
|  |   }.freeze | ||||||
|  | 
 | ||||||
|  |   class PdfiumError < StandardError; end | ||||||
|  | 
 | ||||||
|  |   def self.error_message(code) | ||||||
|  |     PDFIUM_ERRORS[code] || "Unknown error code: #{code}" | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def self.check_last_error(context_message = 'PDFium operation failed') | ||||||
|  |     error_code = FPDF_GetLastError() | ||||||
|  | 
 | ||||||
|  |     return if error_code == FPDF_ERR_SUCCESS | ||||||
|  | 
 | ||||||
|  |     raise PdfiumError, "#{context_message}: #{error_message(error_code)} (Code: #{error_code})" | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   class Document | ||||||
|  |     attr_reader :document_ptr, :form_handle | ||||||
|  | 
 | ||||||
|  |     def initialize(document_ptr, source_buffer = nil) | ||||||
|  |       raise ArgumentError, 'document_ptr cannot be nil' if document_ptr.nil? || document_ptr.null? | ||||||
|  | 
 | ||||||
|  |       @document_ptr = document_ptr | ||||||
|  | 
 | ||||||
|  |       @pages = {} | ||||||
|  |       @closed = false | ||||||
|  |       @source_buffer = source_buffer | ||||||
|  |       @form_handle = FFI::Pointer::NULL | ||||||
|  |       @form_fill_info_mem = FFI::Pointer::NULL | ||||||
|  | 
 | ||||||
|  |       init_form_fill_environment | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def init_form_fill_environment | ||||||
|  |       return if @document_ptr.null? | ||||||
|  | 
 | ||||||
|  |       @form_fill_info_mem = FFI::MemoryPointer.new(FPDF_FORMFILLINFO_V2.size) | ||||||
|  | 
 | ||||||
|  |       form_fill_info_struct = FPDF_FORMFILLINFO_V2.new(@form_fill_info_mem) | ||||||
|  |       form_fill_info_struct[:version] = 2 | ||||||
|  | 
 | ||||||
|  |       @form_handle = Pdfium.FPDFDOC_InitFormFillEnvironment(@document_ptr, @form_fill_info_mem) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def page_count | ||||||
|  |       @page_count ||= Pdfium.FPDF_GetPageCount(@document_ptr) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def self.open_file(file_path, password = nil) | ||||||
|  |       doc_ptr = Pdfium.FPDF_LoadDocument(file_path, password) | ||||||
|  | 
 | ||||||
|  |       if doc_ptr.null? | ||||||
|  |         Pdfium.check_last_error("Failed to load document from file '#{file_path}'") | ||||||
|  | 
 | ||||||
|  |         raise PdfiumError, "Failed to load document from file '#{file_path}', pointer is NULL." | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       doc = new(doc_ptr) | ||||||
|  | 
 | ||||||
|  |       return doc unless block_given? | ||||||
|  | 
 | ||||||
|  |       begin | ||||||
|  |         yield doc | ||||||
|  |       ensure | ||||||
|  |         doc.close | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def self.open_bytes(bytes, password = nil) | ||||||
|  |       buffer = FFI::MemoryPointer.new(:char, bytes.bytesize) | ||||||
|  |       buffer.put_bytes(0, bytes) | ||||||
|  | 
 | ||||||
|  |       doc_ptr = Pdfium.FPDF_LoadMemDocument(buffer, bytes.bytesize, password) | ||||||
|  | 
 | ||||||
|  |       if doc_ptr.null? | ||||||
|  |         Pdfium.check_last_error('Failed to load document from memory') | ||||||
|  | 
 | ||||||
|  |         raise PdfiumError, 'Failed to load document from memory, pointer is NULL.' | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       doc = new(doc_ptr, buffer) | ||||||
|  | 
 | ||||||
|  |       return doc unless block_given? | ||||||
|  | 
 | ||||||
|  |       begin | ||||||
|  |         yield doc | ||||||
|  |       ensure | ||||||
|  |         doc.close | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def closed? | ||||||
|  |       @closed | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def ensure_not_closed! | ||||||
|  |       raise PdfiumError, 'Document is closed.' if closed? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def get_page(page_index) | ||||||
|  |       ensure_not_closed! | ||||||
|  | 
 | ||||||
|  |       unless page_index.is_a?(Integer) && page_index >= 0 && page_index < page_count | ||||||
|  |         raise PdfiumError, "Page index #{page_index} out of range (0..#{page_count - 1})" | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       @pages[page_index] ||= Page.new(self, page_index) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def close | ||||||
|  |       return if closed? | ||||||
|  | 
 | ||||||
|  |       @pages.each_value { |page| page.close unless page.closed? } | ||||||
|  |       @pages.clear | ||||||
|  | 
 | ||||||
|  |       unless @form_handle.null? | ||||||
|  |         Pdfium.FPDFDOC_ExitFormFillEnvironment(@form_handle) | ||||||
|  | 
 | ||||||
|  |         @form_handle = FFI::Pointer::NULL | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       if @form_fill_info_mem && !@form_fill_info_mem.null? | ||||||
|  |         @form_fill_info_mem.free | ||||||
|  |         @form_fill_info_mem = FFI::Pointer::NULL | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       Pdfium.FPDF_CloseDocument(@document_ptr) unless @document_ptr.null? | ||||||
|  | 
 | ||||||
|  |       @document_ptr = FFI::Pointer::NULL | ||||||
|  |       @source_buffer = nil | ||||||
|  | 
 | ||||||
|  |       @closed = true | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   class Page | ||||||
|  |     attr_reader :document, :page_index, :page_ptr | ||||||
|  | 
 | ||||||
|  |     def initialize(document, page_index) | ||||||
|  |       raise ArgumentError, 'Document object is required' unless document.is_a?(Pdfium::Document) | ||||||
|  | 
 | ||||||
|  |       @document = document | ||||||
|  |       @document.ensure_not_closed! | ||||||
|  | 
 | ||||||
|  |       @page_index = page_index | ||||||
|  | 
 | ||||||
|  |       @page_ptr = Pdfium.FPDF_LoadPage(document.document_ptr, page_index) | ||||||
|  | 
 | ||||||
|  |       if @page_ptr.null? | ||||||
|  |         Pdfium.check_last_error("Failed to load page #{page_index}") | ||||||
|  | 
 | ||||||
|  |         raise PdfiumError, "Failed to load page #{page_index}, pointer is NULL." | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       @closed = false | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def width | ||||||
|  |       @width ||= Pdfium.FPDF_GetPageWidthF(@page_ptr) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def height | ||||||
|  |       @height ||= Pdfium.FPDF_GetPageHeightF(@page_ptr) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def closed? | ||||||
|  |       @closed | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def form_handle | ||||||
|  |       @document.form_handle | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def ensure_not_closed! | ||||||
|  |       raise PdfiumError, 'Page is closed.' if closed? | ||||||
|  | 
 | ||||||
|  |       @document.ensure_not_closed! | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def render_to_bitmap(width: nil, height: nil, scale: nil, background_color: 0xFFFFFFFF, | ||||||
|  |                          flags: FPDF_ANNOT | FPDF_LCD_TEXT | FPDF_NO_NATIVETEXT | FPDF_REVERSE_BYTE_ORDER) | ||||||
|  |       ensure_not_closed! | ||||||
|  | 
 | ||||||
|  |       render_width, render_height = calculate_render_dimensions(width, height, scale) | ||||||
|  | 
 | ||||||
|  |       bitmap_ptr = Pdfium.FPDFBitmap_Create(render_width, render_height, 1) | ||||||
|  | 
 | ||||||
|  |       if bitmap_ptr.null? | ||||||
|  |         Pdfium.check_last_error('Failed to create bitmap (potential pre-existing error)') | ||||||
|  | 
 | ||||||
|  |         raise PdfiumError, 'Failed to create bitmap (FPDFBitmap_Create returned NULL)' | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       Pdfium.FPDFBitmap_FillRect(bitmap_ptr, 0, 0, render_width, render_height, background_color) | ||||||
|  | 
 | ||||||
|  |       Pdfium.FPDF_RenderPageBitmap(bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags) | ||||||
|  | 
 | ||||||
|  |       Pdfium.check_last_error('Failed to render page to bitmap') | ||||||
|  | 
 | ||||||
|  |       unless form_handle.null? | ||||||
|  |         Pdfium.FPDF_FFLDraw(form_handle, bitmap_ptr, page_ptr, 0, 0, render_width, render_height, 0, flags) | ||||||
|  | 
 | ||||||
|  |         Pdfium.check_last_error('Call to FPDF_FFLDraw completed (check for rendering issues if any)') | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       buffer_ptr = Pdfium.FPDFBitmap_GetBuffer(bitmap_ptr) | ||||||
|  |       stride = Pdfium.FPDFBitmap_GetStride(bitmap_ptr) | ||||||
|  | 
 | ||||||
|  |       bitmap_data = buffer_ptr.read_bytes(stride * render_height) | ||||||
|  | 
 | ||||||
|  |       [bitmap_data, render_width, render_height] | ||||||
|  |     ensure | ||||||
|  |       Pdfium.FPDFBitmap_Destroy(bitmap_ptr) if bitmap_ptr && !bitmap_ptr.null? | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def close | ||||||
|  |       return if closed? | ||||||
|  | 
 | ||||||
|  |       Pdfium.FPDF_ClosePage(@page_ptr) unless @page_ptr.null? | ||||||
|  | 
 | ||||||
|  |       @page_ptr = FFI::Pointer::NULL | ||||||
|  | 
 | ||||||
|  |       @closed = true | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     private | ||||||
|  | 
 | ||||||
|  |     def calculate_render_dimensions(width_param, height_param, scale_param) | ||||||
|  |       if scale_param | ||||||
|  |         render_width = (width * scale_param).round | ||||||
|  |         render_height = (height * scale_param).round | ||||||
|  |       elsif width_param || height_param | ||||||
|  |         if width_param && height_param | ||||||
|  |           render_width = width_param | ||||||
|  |           render_height = height_param | ||||||
|  |         elsif width_param | ||||||
|  |           scale_factor = width_param.to_f / width | ||||||
|  |           render_width = width_param | ||||||
|  |           render_height = (height * scale_factor).round | ||||||
|  |         else | ||||||
|  |           scale_factor = height_param.to_f / height | ||||||
|  |           render_width = (width * scale_factor).round | ||||||
|  |           render_height = height_param | ||||||
|  |         end | ||||||
|  |       else | ||||||
|  |         render_width = width.to_i | ||||||
|  |         render_height = height.to_i | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       [render_width.clamp(1, MAX_SIZE), render_height.clamp(1, MAX_SIZE)] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def self.initialize_library | ||||||
|  |     config_mem = FFI::MemoryPointer.new(FPDF_LIBRARY_CONFIG.size) | ||||||
|  | 
 | ||||||
|  |     config_struct = FPDF_LIBRARY_CONFIG.new(config_mem) | ||||||
|  |     config_struct[:version] = 2 | ||||||
|  |     config_struct[:m_pUserFontPaths] = FFI::Pointer::NULL | ||||||
|  |     config_struct[:m_pIsolate] = FFI::Pointer::NULL | ||||||
|  |     config_struct[:m_v8EmbedderSlot] = 0 | ||||||
|  | 
 | ||||||
|  |     FPDF_InitLibraryWithConfig(config_mem) | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def self.cleanup_library | ||||||
|  |     FPDF_DestroyLibrary() | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   initialize_library | ||||||
|  | 
 | ||||||
|  |   at_exit do | ||||||
|  |     cleanup_library | ||||||
|  |   end | ||||||
|  | end | ||||||
| @ -0,0 +1,8 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | FactoryBot.define do | ||||||
|  |   factory :template_access do | ||||||
|  |     template | ||||||
|  |     user | ||||||
|  |   end | ||||||
|  | end | ||||||
					Loading…
					
					
				
		Reference in new issue