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
|
||||
@ -1,25 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Submission Preview' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account:) }
|
||||
let(:template) { create(:template, account:, author: user) }
|
||||
|
||||
context 'when not submitted' do
|
||||
let(:submission) { create(:submission, template:, created_by_user: user) }
|
||||
let(:submission) { create(:submission, :with_submitters, template:, created_by_user: user) }
|
||||
|
||||
before do
|
||||
template.submitters.map { |s| create(:submitter, submission:, uuid: s['uuid']) }
|
||||
context 'when user is signed in' do
|
||||
before do
|
||||
sign_in(user)
|
||||
|
||||
sign_in(user)
|
||||
visit submissions_preview_path(slug: submission.slug)
|
||||
end
|
||||
|
||||
visit submissions_preview_path(slug: submission.slug)
|
||||
it 'completes the form' do
|
||||
expect(page).to have_content('Not completed')
|
||||
end
|
||||
end
|
||||
|
||||
it 'completes the form' do
|
||||
expect(page).to have_content('Not completed')
|
||||
context 'when user is not signed in' do
|
||||
context 'when submission is not completed' do
|
||||
before do
|
||||
create(:encrypted_config, account:, key: EncryptedConfig::EMAIL_SMTP_KEY, value: '{}')
|
||||
|
||||
submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
|
||||
|
||||
visit submissions_preview_path(slug: submission.slug)
|
||||
end
|
||||
|
||||
it "sends a copy to the submitter's email" do
|
||||
fill_in 'Email', with: submission.submitters.first.email
|
||||
click_button 'Send copy to Email'
|
||||
|
||||
expect(page).to have_content('Email has been sent.')
|
||||
end
|
||||
|
||||
it 'shows an error for an email not associated with the submission' do
|
||||
fill_in 'Email', with: 'john.due@example.com'
|
||||
click_button 'Send copy to Email'
|
||||
|
||||
expect(page).to have_content('Please enter your email address associated with the completed submission.')
|
||||
end
|
||||
end
|
||||
|
||||
it "doesn't display the email form if SMTP is not configured" do
|
||||
submission.submitters.each { |s| s.update(completed_at: 1.day.ago) }
|
||||
|
||||
visit submissions_preview_path(slug: submission.slug)
|
||||
|
||||
expect(page).to have_content(template.name)
|
||||
expect(page).not_to have_field('Email')
|
||||
expect(page).not_to have_content('Send copy to Email')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Loading…
Reference in new issue