add Pdfium acro fields detector

This commit is contained in:
Alex Turchyn
2026-08-11 23:25:45 +03:00
committed by Pete Matsyburka
parent e361cbabf4
commit 185827c951
3 changed files with 869 additions and 31 deletions
+611 -31
View File
@@ -109,12 +109,6 @@ class Pdfium
attach_function :FPDF_GetTrailerEnds, %i[FPDF_DOCUMENT pointer ulong], :ulong
attach_function :FPDF_DocumentHasValidCrossReferenceTable, [:FPDF_DOCUMENT], :int
attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int
attach_function :FPDFPage_GetAnnot, %i[FPDF_PAGE int], :FPDF_ANNOTATION
attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void
attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetRect, %i[FPDF_ANNOTATION pointer], :int
attach_function :FPDFBookmark_GetFirstChild, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK
attach_function :FPDFBookmark_GetNextSibling, %i[FPDF_DOCUMENT FPDF_BOOKMARK], :FPDF_BOOKMARK
attach_function :FPDFBookmark_GetTitle, %i[FPDF_BOOKMARK pointer ulong], :ulong
@@ -219,6 +213,14 @@ class Pdfium
FPDF_PAGEOBJ_SHADING = 4
FPDF_PAGEOBJ_FORM = 5
PAGE_OBJECT_TYPES = {
FPDF_PAGEOBJ_TEXT => :text,
FPDF_PAGEOBJ_PATH => :path,
FPDF_PAGEOBJ_IMAGE => :image,
FPDF_PAGEOBJ_SHADING => :shading,
FPDF_PAGEOBJ_FORM => :form
}.freeze
# Path segment types
FPDF_SEGMENT_UNKNOWN = -1
FPDF_SEGMENT_LINETO = 0
@@ -272,6 +274,73 @@ class Pdfium
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
attach_function :FORM_OnAfterLoadPage, %i[FPDF_PAGE FPDF_FORMHANDLE], :void
attach_function :FORM_OnBeforeClosePage, %i[FPDF_PAGE FPDF_FORMHANDLE], :void
attach_function :FPDFPage_GetAnnotCount, [:FPDF_PAGE], :int
begin
attach_function :FPDFPage_GetAnnotCountRaw, %i[FPDF_DOCUMENT int], :int
rescue FFI::NotFoundError
define_singleton_method(:FPDFPage_GetAnnotCountRaw) { |*| -1 } # rubocop:disable Naming/MethodName
end
attach_function :FPDFPage_GetAnnot, %i[FPDF_PAGE int], :FPDF_ANNOTATION
attach_function :FPDFPage_CloseAnnot, [:FPDF_ANNOTATION], :void
attach_function :FPDFAnnot_GetSubtype, [:FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetRect, %i[FPDF_ANNOTATION pointer], :int
attach_function :FPDFAnnot_GetNumberValue, %i[FPDF_ANNOTATION string pointer], :int
attach_function :FPDFAnnot_GetStringValue, %i[FPDF_ANNOTATION string pointer ulong], :ulong
attach_function :FPDFAnnot_GetFormFieldType, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetFormFieldFlags, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetFormFieldName, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong
attach_function :FPDFAnnot_GetFormFieldAlternateName, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong
attach_function :FPDFAnnot_GetFormFieldValue, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong
attach_function :FPDFAnnot_GetFormFieldExportValue, %i[FPDF_FORMHANDLE FPDF_ANNOTATION pointer ulong], :ulong
attach_function :FPDFAnnot_GetFormControlCount, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetFormControlIndex, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetOptionCount, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetOptionLabel, %i[FPDF_FORMHANDLE FPDF_ANNOTATION int pointer ulong], :ulong
attach_function :FPDFAnnot_IsChecked, %i[FPDF_FORMHANDLE FPDF_ANNOTATION], :int
attach_function :FPDFAnnot_GetFormAdditionalActionJavaScript,
%i[FPDF_FORMHANDLE FPDF_ANNOTATION int pointer ulong], :ulong
FPDF_ANNOT_LINK = 2
FPDF_ANNOT_WIDGET = 20
FPDF_FORMFIELD_UNKNOWN = 0
FPDF_FORMFIELD_PUSHBUTTON = 1
FPDF_FORMFIELD_CHECKBOX = 2
FPDF_FORMFIELD_RADIOBUTTON = 3
FPDF_FORMFIELD_COMBOBOX = 4
FPDF_FORMFIELD_LISTBOX = 5
FPDF_FORMFIELD_TEXTFIELD = 6
FPDF_FORMFIELD_SIGNATURE = 7
FPDF_FORMFLAG_REQUIRED = 1 << 1
FPDF_FORMFLAG_TEXT_PASSWORD = 1 << 13
FPDF_FORMFLAG_BUTTON_PUSH = 1 << 16
FPDF_FORMFLAG_TEXT_FILE_SELECT = 1 << 20
FPDF_FORMFLAG_TEXT_COMB = 1 << 24
FPDF_FORMFLAG_TEXT_RICH_TEXT = 1 << 25
TEXT_TYPE_FLAGS = FPDF_FORMFLAG_TEXT_FILE_SELECT |
FPDF_FORMFLAG_TEXT_PASSWORD |
FPDF_FORMFLAG_TEXT_RICH_TEXT
FPDF_ANNOT_AACTION_KEY_STROKE = 12
FPDF_ANNOT_AACTION_FORMAT = 13
typedef :pointer, :FPDF_LINK
typedef :pointer, :FPDF_ACTION
attach_function :FPDFAnnot_GetLink, [:FPDF_ANNOTATION], :FPDF_LINK
attach_function :FPDFLink_GetAction, [:FPDF_LINK], :FPDF_ACTION
attach_function :FPDFAction_GetType, [:FPDF_ACTION], :ulong
attach_function :FPDFAction_GetURIPath, %i[FPDF_DOCUMENT FPDF_ACTION pointer ulong], :ulong
PDFACTION_URI = 3
attach_function :FPDFPage_Flatten, %i[FPDF_PAGE int], :int
FLAT_NORMALDISPLAY = 0
@@ -303,6 +372,7 @@ class Pdfium
attach_function :FPDFPage_SetRotation, %i[FPDF_PAGE int], :void
attach_function :FPDFPage_TransFormWithClip, %i[FPDF_PAGE pointer pointer], :int
attach_function :FPDFPage_TransformAnnots, %i[FPDF_PAGE double double double double double double], :void
attach_function :FPDF_GetPageBoundingBox, %i[FPDF_PAGE pointer], :int
attach_function :FPDFPage_GetMediaBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
attach_function :FPDFPage_SetMediaBox, %i[FPDF_PAGE float float float float], :void
attach_function :FPDFPage_GetCropBox, %i[FPDF_PAGE pointer pointer pointer pointer], :int
@@ -383,6 +453,60 @@ class Pdfium
yield instance
end
def self.transform_rect(bounds, box:, rotation:)
x0, y0, x1, y1 = bounds
box_x0, box_y0, box_x1, box_y1 = box
box_width = box_x1 - box_x0
box_height = box_y1 - box_y0
left = x0 - box_x0
right = x1 - box_x0
bottom = y0 - box_y0
top = y1 - box_y0
case rotation
when 1
[bottom, left, top - bottom, right - left, box_height, box_width]
when 2
[box_width - right, bottom, right - left, top - bottom, box_width, box_height]
when 3
[box_height - top, box_width - right, top - bottom, right - left, box_height, box_width]
else
[left, box_height - top, right - left, top - bottom, box_width, box_height]
end
end
def self.read_wide_string
length = yield(FFI::Pointer::NULL, 0)
return if length <= 2
buffer = FFI::MemoryPointer.new(:uint8, length)
length = yield(buffer, length)
return if length <= 2
buffer.read_bytes(length - 2)
.force_encoding('UTF-16LE')
.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
end
def self.read_byte_string
length = yield(FFI::Pointer::NULL, 0)
return if length <= 1
buffer = FFI::MemoryPointer.new(:uint8, length)
length = yield(buffer, length)
return if length <= 1
buffer.read_bytes(length - 1).force_encoding('UTF-8').scrub
end
def self.check_last_error(context_message = 'PDFium operation failed')
error_code = FPDF_GetLastError()
@@ -401,6 +525,7 @@ class Pdfium
@document_ptr = document_ptr
@pages = {}
@annot_counts = {}
@closed = false
@source_buffer = source_buffer
@form_handle = FFI::Pointer::NULL
@@ -597,6 +722,14 @@ class Pdfium
Pdfium.FPDF_DocumentHasValidCrossReferenceTable(@document_ptr) == 1
end
def annot_count(page_index)
@annot_counts[page_index] ||= Pdfium.FPDFPage_GetAnnotCountRaw(@document_ptr, page_index)
end
def reset_annot_count(page_index)
@annot_counts.delete(page_index)
end
def signature_count
@signature_count ||= Pdfium.FPDF_GetSignatureCount(@document_ptr)
end
@@ -757,8 +890,6 @@ class Pdfium
end
class Page
RECT_KEYS = %i[left top right bottom].freeze
attr_reader :document, :page_index, :page_ptr
def initialize(document, page_index)
@@ -789,30 +920,36 @@ class Pdfium
end
def annotations
(0...Pdfium.FPDFPage_GetAnnotCount(page_ptr)).filter_map do |index|
annotation = Pdfium.FPDFPage_GetAnnot(page_ptr, index)
ensure_not_closed!
next if annotation.null?
begin
rect = Pdfium::FS_RECTF.new
Pdfium.FPDFAnnot_GetRect(annotation, rect)
[Pdfium.FPDFAnnot_GetSubtype(annotation),
*RECT_KEYS.map { |key| rect[key].round(3) }]
ensure
Pdfium.FPDFPage_CloseAnnot(annotation)
@annotations ||=
(0...Pdfium.FPDFPage_GetAnnotCount(page_ptr)).filter_map do |index|
with_annotation(index) do |handle|
Annotation.new(page: self, index:, subtype: handle.subtype, rect: handle.rect)
end
end
end
end
def objects
(0...Pdfium.FPDFPage_CountObjects(page_ptr)).map do |index|
object = Pdfium.FPDFPage_GetObject(page_ptr, index)
bounds = Array.new(4) { FFI::MemoryPointer.new(:float) }
Pdfium.FPDFPageObj_GetBounds(object, *bounds)
ensure_not_closed!
[Pdfium.FPDFPageObj_GetType(object), *bounds.map { |b| b.read_float.round(3) }]
@objects ||=
(0...Pdfium.FPDFPage_CountObjects(page_ptr)).map do |index|
PageObject.new(page: self, index:)
end
end
def with_annotation(index)
ensure_not_closed!
annot_ptr = Pdfium.FPDFPage_GetAnnot(page_ptr, index)
return if annot_ptr.null?
begin
yield AnnotationHandle.new(self, annot_ptr)
ensure
Pdfium.FPDFPage_CloseAnnot(annot_ptr)
end
end
@@ -826,6 +963,18 @@ class Pdfium
@rotation = value
end
def box
@box ||= read_bounding_box || [0, 0, 612, 792]
end
def load_page_view
return if @page_view || form_handle.null?
Pdfium.FORM_OnAfterLoadPage(@page_ptr, form_handle)
@page_view = true
end
def closed?
@closed
end
@@ -987,7 +1136,7 @@ class Pdfium
@document.add_presave_hook(:cleanup) { @document.cleanup }
reset_text_memoization
reset_memoization
nil
end
@@ -1085,7 +1234,7 @@ class Pdfium
Pdfium.FPDFPage_GenerateContent(@page_ptr) if unwrapped
reset_text_memoization if unwrapped
reset_memoization if unwrapped
end
def find_form_object(rect_bounds = nil)
@@ -1202,12 +1351,14 @@ class Pdfium
end
end
def reset_text_memoization
def reset_memoization
remove_instance_variable(:@text) if defined?(@text)
@text_nodes = nil
@text_objects = nil
@line_nodes = nil
@annotations = nil
@objects = nil
end
def remove_page_object(object_ptr)
@@ -1603,23 +1754,32 @@ class Pdfium
end
def reload
close_page_view
Pdfium.FPDF_ClosePage(@page_ptr)
@page_ptr = Pdfium.FPDF_LoadPage(@document.document_ptr, @page_index)
raise PdfiumError, "Failed to reload page #{page_index}" if @page_ptr.null?
@page_view = false
@rotation = nil
@width = nil
@height = nil
@box = nil
reset_text_memoization
@document.reset_annot_count(@page_index)
reset_memoization
end
def close
return if closed?
Pdfium.FPDF_ClosePage(@page_ptr) unless @page_ptr.null?
unless @page_ptr.null?
close_page_view
Pdfium.FPDF_ClosePage(@page_ptr)
end
@page_ptr = FFI::Pointer::NULL
@@ -1628,6 +1788,25 @@ class Pdfium
private
def close_page_view
Pdfium.FORM_OnBeforeClosePage(@page_ptr, form_handle) unless form_handle.null?
@page_view = false
end
def read_bounding_box
rect = Pdfium::FS_RECTF.new
return unless Pdfium.FPDF_GetPageBoundingBox(page_ptr, rect) == 1
x0, x1 = [rect[:left], rect[:right]].minmax
y0, y1 = [rect[:bottom], rect[:top]].minmax
return if x1 - x0 <= 0 || y1 - y0 <= 0
[x0, y0, x1, y1]
end
def calculate_render_dimensions(width_param, height_param, scale_param)
if scale_param
render_width = (width * scale_param).round
@@ -1654,6 +1833,407 @@ class Pdfium
end
end
class AnnotationHandle
attr_reader :page, :annot_ptr
delegate :form_handle, to: :page
def initialize(page, annot_ptr)
@page = page
@annot_ptr = annot_ptr
end
def subtype
Pdfium.FPDFAnnot_GetSubtype(annot_ptr)
end
def rect
rect = Pdfium::FS_RECTF.new
return unless Pdfium.FPDFAnnot_GetRect(annot_ptr, rect) == 1
left, right = [rect[:left], rect[:right]].minmax
bottom, top = [rect[:bottom], rect[:top]].minmax
[left, bottom, right, top]
end
def field_type
Pdfium.FPDFAnnot_GetFormFieldType(form_handle, annot_ptr)
end
def field_flags
Pdfium.FPDFAnnot_GetFormFieldFlags(form_handle, annot_ptr)
end
def field_name
read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldName(form_handle, annot_ptr, buffer, length) }
end
def alternate_name
read_wide do |buffer, length|
Pdfium.FPDFAnnot_GetFormFieldAlternateName(form_handle, annot_ptr, buffer, length)
end
end
def field_value
read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldValue(form_handle, annot_ptr, buffer, length) }
end
def export_value
read_wide { |buffer, length| Pdfium.FPDFAnnot_GetFormFieldExportValue(form_handle, annot_ptr, buffer, length) }
end
def control_count
Pdfium.FPDFAnnot_GetFormControlCount(form_handle, annot_ptr)
end
def control_index
Pdfium.FPDFAnnot_GetFormControlIndex(form_handle, annot_ptr)
end
def checked?
Pdfium.FPDFAnnot_IsChecked(form_handle, annot_ptr) == 1
end
def option_labels
count = Pdfium.FPDFAnnot_GetOptionCount(form_handle, annot_ptr)
return [] if count < 1
(0...count).map do |index|
read_wide do |buffer, length|
Pdfium.FPDFAnnot_GetOptionLabel(form_handle, annot_ptr, index, buffer, length)
end.to_s
end
end
def action_javascript(event)
read_wide do |buffer, length|
Pdfium.FPDFAnnot_GetFormAdditionalActionJavaScript(form_handle, annot_ptr, event, buffer, length)
end
end
def link_uri
link = Pdfium.FPDFAnnot_GetLink(annot_ptr)
return if link.null?
action = Pdfium.FPDFLink_GetAction(link)
return if action.null? || Pdfium.FPDFAction_GetType(action) != Pdfium::PDFACTION_URI
document_ptr = page.document.document_ptr
Pdfium.read_byte_string { |buffer, length| Pdfium.FPDFAction_GetURIPath(document_ptr, action, buffer, length) }
end
def number_value(key)
value = FFI::MemoryPointer.new(:float)
return unless Pdfium.FPDFAnnot_GetNumberValue(annot_ptr, key, value) == 1
value.read_float
end
def string_value(key)
read_wide { |buffer, length| Pdfium.FPDFAnnot_GetStringValue(annot_ptr, key, buffer, length) }
end
private
def read_wide(&)
Pdfium.read_wide_string(&)
end
end
class Annotation
FIELD_TYPES = {
Pdfium::FPDF_FORMFIELD_PUSHBUTTON => :pushbutton,
Pdfium::FPDF_FORMFIELD_CHECKBOX => :checkbox,
Pdfium::FPDF_FORMFIELD_RADIOBUTTON => :radio,
Pdfium::FPDF_FORMFIELD_COMBOBOX => :combobox,
Pdfium::FPDF_FORMFIELD_LISTBOX => :listbox,
Pdfium::FPDF_FORMFIELD_TEXTFIELD => :text,
Pdfium::FPDF_FORMFIELD_SIGNATURE => :signature
}.freeze
class Link
attr_reader :url
def initialize(url)
@url = url
end
end
attr_reader :page, :index, :subtype, :bounds
delegate :page_index, to: :page
def initialize(page:, index:, subtype:, rect: nil)
@page = page
@index = index
@subtype = subtype
@bounds = rect.to_a
end
def left
bounds[0]
end
def bottom
bounds[1]
end
def right
bounds[2]
end
def top
bounds[3]
end
def widget?
subtype == Pdfium::FPDF_ANNOT_WIDGET
end
def link?
subtype == Pdfium::FPDF_ANNOT_LINK
end
def rect?
bounds.any?
end
def width
right - left
end
def height
top - bottom
end
def link
@link = link? ? page.with_annotation(index) { |handle| Link.new(handle.link_uri) } : nil unless defined?(@link)
@link
end
def field
@field = widget? ? page.with_annotation(index) { |handle| Field.new(handle) } : nil unless defined?(@field)
@field
end
def to_a
[index, subtype, *bounds]
end
def ==(other)
other.is_a?(Annotation) && to_a == other.to_a
end
alias eql? ==
def hash
[Annotation, *to_a].hash
end
end
class Field
attr_reader :type, :flags, :name, :alternate_name, :value, :export_value, :control_index,
:control_count, :options, :checked, :max_len, :quadding, :format_js,
:keystroke_js, :dict_type, :partial_name
def initialize(handle)
code = handle.field_type
@detached = code.negative?
@dict_type = handle.string_value('FT')
@partial_name = handle.string_value('T')
@options = []
@detached ? read_detached(handle) : read_form(handle, code)
end
def checked?
checked == true
end
def detached?
@detached
end
def required?
flags.to_i.anybits?(Pdfium::FPDF_FORMFLAG_REQUIRED)
end
def comb?
flags.to_i.anybits?(Pdfium::FPDF_FORMFLAG_TEXT_COMB) && flags.to_i.nobits?(Pdfium::TEXT_TYPE_FLAGS)
end
def button?
type.in?(%i[checkbox radio])
end
def own?
dict_type.present? && partial_name.present?
end
private
def read_form(handle, code)
@type = Annotation::FIELD_TYPES.fetch(code, :unknown)
@flags = handle.field_flags
@name = handle.field_name.to_s
@alternate_name = handle.alternate_name
@value = handle.field_value
@control_index = handle.control_index
@control_count = handle.control_count
read_form_extras(handle)
end
def read_form_extras(handle)
case type
when :checkbox, :radio
handle.page.load_page_view
@checked = handle.checked?
@export_value = handle.export_value
@options = handle.option_labels if control_index.zero?
when :combobox, :listbox
@options = handle.option_labels
when :text
read_text_extras(handle)
end
end
def read_text_extras(handle)
@max_len = handle.number_value('MaxLen')
@quadding = handle.number_value('Q')
@format_js = handle.action_javascript(Pdfium::FPDF_ANNOT_AACTION_FORMAT)
@keystroke_js = handle.action_javascript(Pdfium::FPDF_ANNOT_AACTION_KEY_STROKE)
end
def read_detached(handle)
@flags = handle.number_value('Ff').to_i
@value = handle.string_value('V')
@name = partial_name.to_s
@alternate_name = handle.string_value('TU')
@control_index = 0
@control_count = 1
@type = detached_type
if type == :text
@max_len = handle.number_value('MaxLen')
@quadding = handle.number_value('Q')
else
@checked = value.present? && value != 'Off'
end
end
def detached_type
case dict_type
when 'Tx' then :text
when 'Sig' then :signature
when 'Btn' then flags.anybits?(Pdfium::FPDF_FORMFLAG_BUTTON_PUSH) ? :pushbutton : :checkbox
else :unknown
end
end
end
class PageObject
attr_reader :page, :index
def initialize(page:, index:)
@page = page
@index = index
end
def type
@type ||= Pdfium::PAGE_OBJECT_TYPES.fetch(Pdfium.FPDFPageObj_GetType(object_ptr), :unknown)
end
def bounds
@bounds ||= read_bounds
end
def left
bounds[0]
end
def bottom
bounds[1]
end
def right
bounds[2]
end
def top
bounds[3]
end
def rect?
bounds.any?
end
def width
right - left
end
def height
top - bottom
end
def text?
type == :text
end
def image?
type == :image
end
def path?
type == :path
end
def form?
type == :form
end
def to_a
[index, type, *bounds]
end
def ==(other)
other.is_a?(PageObject) && to_a == other.to_a
end
alias eql? ==
def hash
[PageObject, *to_a].hash
end
private
def object_ptr
page.ensure_not_closed!
Pdfium.FPDFPage_GetObject(page.page_ptr, index)
end
def read_bounds
buffer = Array.new(4) { FFI::MemoryPointer.new(:float) }
return [] unless Pdfium.FPDFPageObj_GetBounds(object_ptr, *buffer) == 1
buffer.map(&:read_float)
end
end
def self.initialize_library
config_mem = FFI::MemoryPointer.new(FPDF_LIBRARY_CONFIG.size)
+47
View File
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module Templates
module BuildPdfiumAnnotations
URI_PREFIXES = %w[https:// http://].freeze
LINKS_LIMIT = 250
module_function
def call(doc)
annotations = []
doc.page_count.times do |page_index|
break if annotations.size >= LINKS_LIMIT
next if doc.annot_count(page_index).zero?
page = doc.get_page(page_index)
geometry = { box: page.box, rotation: page.rotation }
page.annotations.each do |annotation|
next unless annotation.link? && annotation.rect?
url = annotation.link.url
next if url.blank? || URI_PREFIXES.none? { |prefix| url.start_with?(prefix) }
annotations << build_external_link_hash(url, annotation, geometry).merge('page' => page_index)
end
end
annotations
end
def build_external_link_hash(url, area, geometry)
x, y, w, h, page_width, page_height = Pdfium.transform_rect(area.bounds, **geometry)
{
'type' => 'external_link',
'value' => url,
'x' => x / page_width,
'y' => y / page_height,
'w' => w / page_width,
'h' => h / page_height
}
end
end
end
+211
View File
@@ -0,0 +1,211 @@
# frozen_string_literal: true
module Templates
module FindPdfiumAcroFields
DATE_JS_PREFIX = 'AFDate_'
SKIP_FIELD_TYPES = %i[unknown pushbutton].freeze
module_function
def call(attachment, doc)
pages = {}
widgets = []
doc.page_count.times do |page_index|
next if doc.annot_count(page_index).zero?
page = doc.get_page(page_index)
pages[page_index] = { box: page.box, rotation: page.rotation }
page.annotations.each do |annotation|
next unless annotation.widget? && annotation.rect?
field = annotation.field
next if field.nil? || field.type.in?(SKIP_FIELD_TYPES)
widgets << annotation
end
end
group_widgets(widgets).filter_map { |field_widgets| build_field(field_widgets, pages, attachment) }
end
def group_widgets(widgets)
widgets.group_by do |annotation|
field = annotation.field
if (field.control_count > 1 && field.own?) || field.detached? || (field.name.blank? && field.control_count <= 1)
[:widget, annotation.page_index, annotation.index]
else
[:field, field.type, field.name]
end
end.values
end
def build_field(widgets, pages, attachment)
widgets = widgets.sort_by.with_index { |annotation, index| [annotation.field.control_index, index] }
areas = widgets.filter_map { |annotation| build_area(annotation, pages[annotation.page_index], attachment) }
return if areas.blank?
field_properties = build_field_properties(widgets)
return if field_properties.blank?
return if field_properties[:default_value].present?
if field_properties[:type] == 'radio'
if areas.size != field_properties[:options].size
field_properties[:options] = build_options(Array.new(areas.size, ''))
end
areas.each_with_index do |area, index|
area[:option_uuid] = field_properties[:options][index][:uuid]
end
end
{
uuid: SecureRandom.uuid,
required: widgets.first.field.required?,
preferences: {},
areas:,
**field_properties
}
end
def build_area(annotation, page, attachment)
x, y, w, h, page_width, page_height = Pdfium.transform_rect(annotation.bounds, **page)
attrs = {
page: annotation.page_index,
x: x / page_width,
y: y / page_height,
w: w / page_width,
h: h / page_height,
attachment_uuid: attachment.uuid
}
return if attrs[:w].zero? || attrs[:h].zero?
field = annotation.field
attrs[:cell_w] = attrs[:w] / field.max_len if field.comb? && field.max_len.to_f.positive?
attrs
end
def build_field_properties(widgets)
field = widgets.first.field
field_name = field.name if field.name.match?(FindAcroFields::FIELD_NAME_REGEXP)
attrs = { name: field_name.to_s }
attrs[:description] = field.alternate_name if field.alternate_name.present? &&
field.alternate_name != field.name &&
!field.alternate_name.in?(FindAcroFields::SKIP_FIELD_DESCRIPTION)
case field.type
when :checkbox, :radio
build_button_properties(attrs, widgets)
when :combobox
build_select_properties(attrs, field)
when :text
build_text_properties(attrs, field)
when :signature
{
**attrs,
type: field.name.to_s.downcase.include?('initials') ? 'initials' : 'signature'
}
else
{}
end.compact
end
def build_button_properties(attrs, widgets)
field = widgets.first.field
options = widgets.find { |w| w.field.options.present? }&.field&.options.to_a
checked = widgets.find { |w| w.field.checked? }
export_values = widgets.filter_map { |w| w.field.export_value.presence }.uniq
if field.type == :radio && options.present?
{
**attrs,
type: 'radio',
options: build_options(options, 'radio'),
default_value: checked && options[checked.field.control_index]
}
elsif field.control_count > 1 && export_values.size > 1
{
**attrs,
type: 'radio',
options: build_options(export_values.map(&:to_sym), 'radio'),
default_value: checked&.field&.export_value
}
else
{
**attrs,
type: 'checkbox',
default_value: checked.present?
}
end
end
def build_select_properties(attrs, field)
return {} if field.options.blank?
{
**attrs,
type: 'select',
options: build_options(field.options, 'select'),
default_value: field.value.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP) ? nil : field.value
}
end
def build_text_properties(attrs, field)
preferences = { align: FindAcroFields::FIELD_ALIGNMENT.fetch(field.quadding.to_i, 'left') }
attrs = { **attrs, preferences: }
if field.comb?
{ **attrs, type: 'cells', default_value: field.value }
elsif date?(field)
format = [field.format_js, field.keystroke_js].compact
.filter_map { |js| js[FindAcroFields::DATE_FORMAT_REGEXP] }
.first
preferences[:format] = format.upcase if format
{ **attrs, type: 'date', default_value: field.value }
else
{ **attrs, type: 'text', default_value: field.value }
end
end
def date?(field)
field.format_js.to_s.include?(DATE_JS_PREFIX) || field.keystroke_js.to_s.include?(DATE_JS_PREFIX)
end
def build_options(values, type = nil)
is_skip_single_value = type.in?(%w[radio multiple]) && values.uniq.size == 1
values.filter_map do |option|
is_option_number = option.is_a?(Symbol) && option.to_s.match?(/\A\d+\z/)
option = option[1] if option.is_a?(Array) && option.size == 2
if option.is_a?(String) || option.is_a?(Symbol)
option = option.to_s.encode('utf-8', invalid: :replace, undef: :replace, replace: '')
end
next if type == 'select' && option.to_s.match?(FindAcroFields::SELECT_PLACEHOLDER_REGEXP)
{
uuid: SecureRandom.uuid,
value: is_option_number || is_skip_single_value ? '' : option
}
end
end
end
end