Files
docuseal/lib/templates/detect_fields.rb
T

534 lines
15 KiB
Ruby
Raw Normal View History

2025-10-28 10:52:37 +02:00
# frozen_string_literal: true
module Templates
module DetectFields
module_function
2026-04-02 12:48:28 +03:00
TextFieldBox = Struct.new(:x, :y, :w, :h) do
2025-11-29 20:13:00 +02:00
def endy
@endy ||= y + h
end
def endx
@endx ||= x + w
end
end
2026-04-02 12:48:28 +03:00
PageNode = Struct.new(:prev, :next, :elem, :page, :attachment_uuid)
2025-11-08 22:45:41 +02:00
DATE_REGEXP = /
(?:
date
| signed\sat
| datum
2025-12-01 10:09:27 +02:00
)[:_\s-]*\z
2025-11-08 22:45:41 +02:00
/ix
NUMBER_REGEXP = /
(?:
price
| \$
| €
| total
| quantity
| prix
| quantité
| preis
| summe
| gesamt(?:betrag)?
| menge
| anzahl
| stückzahl
2025-12-01 10:09:27 +02:00
)[:_\s-]*\z
2025-11-08 22:45:41 +02:00
/ix
SIGNATURE_REGEXP = /
(?:
signature
| sign\shere
| sign
| signez\sici
| signer\sici
| unterschrift
| unterschreiben
| unterzeichnen
2025-12-01 10:09:27 +02:00
)[:_\s-]*\z
2025-11-08 22:45:41 +02:00
/ix
2025-11-29 20:13:00 +02:00
LINEBREAK = ["\n", "\r"].freeze
2025-12-01 10:15:23 +02:00
CHECKBOXES = ['☐', '□'].freeze
2025-11-29 20:13:00 +02:00
2025-11-08 22:45:41 +02:00
# rubocop:disable Metrics, Style
2026-01-03 16:45:05 +02:00
def call(io, attachment: nil, confidence: 0.3, temperature: 1, inference: Templates::ImageToFields, nms: 0.1,
2026-01-18 17:22:55 +02:00
nmm: 0.5, split_page: false, aspect_ratio: true, padding: 20, regexp_type: true, page_number: nil, &)
2025-11-08 22:45:41 +02:00
fields, head_node =
if attachment&.image?
2026-01-07 11:24:02 +02:00
process_image_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
2026-01-18 17:22:55 +02:00
temperature:, aspect_ratio:, padding:, page_number:, &)
2025-11-08 22:45:41 +02:00
else
2026-01-07 11:24:02 +02:00
process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, split_page:, inference:,
2026-01-18 17:22:55 +02:00
temperature:, aspect_ratio:, regexp_type:, padding:, page_number:, &)
2025-11-08 22:45:41 +02:00
end
[fields, head_node]
2025-10-28 10:52:37 +02:00
end
2026-01-07 11:24:02 +02:00
def process_image_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
2026-01-18 17:22:55 +02:00
split_page: false, aspect_ratio: false, padding: nil, page_number: nil)
return [[], nil] if page_number && page_number != 0
2026-05-21 15:38:16 +03:00
image = ImageUtils.load_vips(io.read, content_type: attachment.content_type)
2025-10-28 10:52:37 +02:00
2026-01-07 11:24:02 +02:00
fields = inference.call(image, confidence:, nms:, nmm:, split_page:,
2025-11-03 17:00:52 +02:00
temperature:, aspect_ratio:, padding:)
2025-10-28 10:52:37 +02:00
2025-11-29 20:13:00 +02:00
fields = sort_fields(fields, y_threshold: 10.0 / image.height)
2025-10-28 10:52:37 +02:00
fields = fields.map do |f|
{
uuid: SecureRandom.uuid,
type: f.type,
2025-11-27 17:00:26 +02:00
required: f.type == 'signature',
2025-10-28 10:52:37 +02:00
preferences: {},
areas: [{
x: f.x,
y: f.y,
w: f.w,
h: f.h,
page: 0,
attachment_uuid: attachment&.uuid
}]
}
end
yield [attachment&.uuid, 0, fields] if block_given?
2025-11-09 12:18:56 +02:00
[fields, nil]
2025-10-28 10:52:37 +02:00
end
2026-01-07 11:24:02 +02:00
def process_pdf_attachment(io, attachment:, confidence:, nms:, nmm:, temperature:, inference:,
2026-01-18 17:22:55 +02:00
split_page: false, aspect_ratio: false, padding: nil, regexp_type: false,
page_number: nil)
2025-10-28 10:52:37 +02:00
doc = Pdfium::Document.open_bytes(io.read)
2025-11-09 12:18:56 +02:00
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
2025-11-08 22:45:41 +02:00
tail_node = head_node
2026-04-18 22:06:58 +03:00
page_indexes = page_number ? [page_number] : (0...doc.page_count).to_a
2026-01-18 17:22:55 +02:00
2026-04-18 22:06:58 +03:00
prep_opts = { aspect_ratio:, padding:, split_page: }
infer_opts = { confidence: confidence / 3.0, nms:, nmm:, temperature: }
2026-01-18 17:22:55 +02:00
2026-04-18 22:06:58 +03:00
image = prepare_page_image(doc.get_page(page_indexes.first), inference:, padding:)
current_args = inference.prepare_input(image, **prep_opts)
current_wait = inference.enqueue(**current_args, **infer_opts)
2025-10-28 10:52:37 +02:00
2026-04-18 22:06:58 +03:00
all_fields = []
2026-01-03 16:45:05 +02:00
2026-04-18 22:06:58 +03:00
page_indexes.each_with_index do |current_page_number, i|
next_n = page_indexes[i + 1]
2025-10-28 10:52:37 +02:00
2026-04-18 22:06:58 +03:00
next_image = next_n ? prepare_page_image(doc.get_page(next_n), inference:, padding:) : nil
next_args = next_image ? inference.prepare_input(next_image, **prep_opts) : nil
2025-10-28 10:52:37 +02:00
2026-04-18 22:06:58 +03:00
outputs = current_wait.call
2025-10-28 10:52:37 +02:00
2026-04-18 22:06:58 +03:00
next_wait = next_args ? inference.enqueue(**next_args, **infer_opts) : nil
2025-10-28 10:52:37 +02:00
2026-04-18 22:06:58 +03:00
fields = inference.process_outputs(outputs, **current_args, **infer_opts)
current_page = doc.get_page(current_page_number)
fields = sort_fields(fields, y_threshold: 10.0 / current_args[:image].height)
text_fields = extract_text_fields_from_page(current_page)
line_fields = extract_line_fields_from_page(current_page)
2025-11-29 20:13:00 +02:00
2026-01-03 16:45:05 +02:00
fields = increase_confidence_for_overlapping_fields(fields, text_fields, confidence:)
fields = increase_confidence_for_overlapping_fields(fields, line_fields, confidence:)
2025-10-28 10:52:37 +02:00
2025-11-08 22:45:41 +02:00
fields = fields.reject { |f| f.confidence < confidence }
2026-04-18 22:06:58 +03:00
field_nodes, tail_node = build_page_nodes(current_page, fields, tail_node, attachment_uuid: attachment&.uuid)
2025-11-08 22:45:41 +02:00
2026-04-18 22:06:58 +03:00
page_fields = field_nodes.map do |node|
2025-11-08 22:45:41 +02:00
field = node.elem
2025-10-28 10:52:37 +02:00
2025-11-09 12:18:56 +02:00
type = regexp_type ? type_from_page_node(node) : field.type
2025-10-28 10:52:37 +02:00
{
uuid: SecureRandom.uuid,
2025-11-09 12:18:56 +02:00
type:,
2025-11-27 17:00:26 +02:00
required: type == 'signature',
2025-10-28 10:52:37 +02:00
preferences: {},
areas: [{
2025-11-08 22:45:41 +02:00
x: field.x, y: field.y,
w: field.w, h: field.h,
2026-01-18 17:22:55 +02:00
page: current_page_number,
2025-10-28 10:52:37 +02:00
attachment_uuid: attachment&.uuid
}]
}
end
2026-04-18 22:06:58 +03:00
yield [attachment&.uuid, current_page_number, page_fields] if block_given?
2025-10-28 10:52:37 +02:00
2026-04-18 22:06:58 +03:00
all_fields.concat(page_fields)
current_args = next_args
current_wait = next_wait
2025-11-02 08:54:55 +02:00
ensure
2026-04-18 22:06:58 +03:00
current_page&.close
2025-10-28 10:52:37 +02:00
end
2025-11-08 22:45:41 +02:00
print_debug(head_node) if Rails.env.development?
2026-04-18 22:06:58 +03:00
[all_fields, head_node]
2025-11-02 08:54:55 +02:00
ensure
doc.close
2025-10-28 10:52:37 +02:00
end
2026-04-18 22:06:58 +03:00
def prepare_page_image(page, inference:, padding:)
size_key = page.width > page.height ? :width : :height
size = padding ? inference.resolution * 1.5 : inference.resolution
data, width, height = page.render_to_bitmap(size_key => size)
Vips::Image.new_from_memory(data, width, height, 4, :uchar)
end
2025-11-29 20:13:00 +02:00
def sort_fields(fields, y_threshold: 0.01)
fields.sort do |a, b|
(a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy
end
end
2025-11-08 22:45:41 +02:00
def print_debug(head_node)
current_node = head_node
index = 0
string = ''.b
loop do
string <<
if current_node.elem.is_a?(String)
current_node.elem
else
"[#{current_node.elem.type == 'checkbox' ? 'Checkbox' : 'Field'}_#{index += 1}]"
end
current_node = current_node.next
break unless current_node
end
Rails.logger.info(string)
end
def type_from_page_node(node)
return node.elem.type unless node.prev.elem.is_a?(String)
return node.elem.type unless node.elem.type == 'text'
string = node.prev.elem
return 'date' if string.match?(DATE_REGEXP)
return 'signature' if string.match?(SIGNATURE_REGEXP)
return 'number' if string.match?(NUMBER_REGEXP)
return 'text'
end
2025-11-09 12:18:56 +02:00
def build_page_nodes(page, fields, tail_node, attachment_uuid: nil)
2025-11-08 22:45:41 +02:00
field_nodes = []
2025-12-01 10:15:23 +02:00
y_threshold = 4.0 / page.height
x_threshold = 30.0 / page.width
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
text_nodes = page.text_nodes
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
current_field = fields.shift
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
index = 0
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
prev_node = nil
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
loop do
node = text_nodes[index]
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
break unless node
2025-11-08 22:45:41 +02:00
2025-11-30 11:42:36 +02:00
if node.content.in?(LINEBREAK)
next_node = text_nodes[index]
2025-12-01 10:15:23 +02:00
if next_node && (next_node.endy - node.endy) < y_threshold
2025-11-30 11:42:36 +02:00
index += 1
next
end
end
2025-11-29 20:13:00 +02:00
loop do
break unless current_field
2025-11-08 22:45:41 +02:00
2025-12-01 10:15:23 +02:00
if ((current_field.endy - node.endy).abs < y_threshold &&
2025-11-29 20:13:00 +02:00
(current_field.x <= node.x || node.content.in?(LINEBREAK))) ||
2026-03-06 11:35:40 +02:00
current_field.endy < node.endy
2025-11-30 11:42:36 +02:00
if tail_node.elem.is_a?(Templates::ImageToFields::Field)
divider =
2025-12-01 10:15:23 +02:00
if (tail_node.elem.endy - current_field.endy).abs > y_threshold
2025-11-30 11:42:36 +02:00
"\n".b
2025-12-01 10:15:23 +02:00
elsif tail_node.elem.endx - current_field.x > x_threshold
2025-11-30 11:42:36 +02:00
"\t".b
else
' '.b
end
text_node = PageNode.new(prev: tail_node, elem: divider, page: page.page_index, attachment_uuid:)
tail_node.next = text_node
tail_node = text_node
2025-12-01 10:15:23 +02:00
elsif prev_node && (prev_node.endy - current_field.endy).abs > y_threshold
2025-11-30 11:42:36 +02:00
text_node = PageNode.new(prev: tail_node, elem: "\n".b, page: page.page_index, attachment_uuid:)
tail_node.next = text_node
tail_node = text_node
end
2025-11-29 20:13:00 +02:00
field_node = PageNode.new(prev: tail_node, elem: current_field, page: page.page_index, attachment_uuid:)
2025-11-30 11:42:36 +02:00
2025-11-29 20:13:00 +02:00
tail_node.next = field_node
tail_node = field_node
field_nodes << tail_node
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
current_field = fields.shift
2025-11-08 22:45:41 +02:00
else
2025-11-29 20:13:00 +02:00
break
2025-11-08 22:45:41 +02:00
end
end
2025-11-29 20:13:00 +02:00
if tail_node.elem.is_a?(Templates::ImageToFields::Field)
2025-11-30 11:42:36 +02:00
prev_field = tail_node.elem
2025-11-29 20:13:00 +02:00
text_node = PageNode.new(prev: tail_node, elem: ''.b, page: page.page_index, attachment_uuid:)
tail_node.next = text_node
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
tail_node = text_node
2025-11-08 22:45:41 +02:00
2025-12-01 10:15:23 +02:00
if (node.endy - prev_field.endy).abs > y_threshold
2025-11-30 11:42:36 +02:00
tail_node.elem << "\n"
2025-12-01 10:15:23 +02:00
elsif (node.x - prev_field.endx) > x_threshold
2025-11-30 11:42:36 +02:00
tail_node.elem << "\t"
end
elsif prev_node
2025-12-01 10:15:23 +02:00
if (node.endy - prev_node.endy) > y_threshold && LINEBREAK.exclude?(prev_node.content)
2025-11-30 11:42:36 +02:00
tail_node.elem << "\n"
2025-12-01 10:15:23 +02:00
elsif (node.x - prev_node.endx) > x_threshold && !tail_node.elem.ends_with?("\t")
2025-11-30 11:42:36 +02:00
tail_node.elem << "\t"
end
2025-11-29 20:13:00 +02:00
end
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
if node.content != '_' || !tail_node.elem.ends_with?('___')
2025-12-01 10:15:23 +02:00
tail_node.elem << node.content unless CHECKBOXES.include?(node.content)
2025-11-29 20:13:00 +02:00
end
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
prev_node = node
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
index += 1
end
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
loop do
break unless current_field
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
field_node = PageNode.new(prev: tail_node, elem: current_field, page: page.page_index, attachment_uuid:)
tail_node.next = field_node
tail_node = field_node
field_nodes << tail_node
2025-11-08 22:45:41 +02:00
2025-11-29 20:13:00 +02:00
current_field = fields.shift
2025-11-08 22:45:41 +02:00
end
2025-11-30 11:42:36 +02:00
if tail_node.elem.is_a?(Templates::ImageToFields::Field)
text_node = PageNode.new(prev: tail_node, elem: "\n".b, page: page.page_index, attachment_uuid:)
tail_node.next = text_node
tail_node = text_node
else
tail_node.elem << "\n"
end
2025-11-08 22:45:41 +02:00
[field_nodes, tail_node]
end
2025-10-28 10:52:37 +02:00
def extract_line_fields_from_page(page)
line_thickness = 5.0 / page.height
vertical_lines, all_horizontal_lines = page.line_nodes.partition { |line| line.tilt == 90 }
horizontal_lines = all_horizontal_lines.reject do |h_line|
next true if h_line.w > 0.7 && (h_line.h < 0.1 || h_line.h < 0.9)
next false if vertical_lines.blank?
h_x_min = h_line.x
h_x_max = h_line.x + h_line.w
h_y_avg = h_line.y + (h_line.h / 2)
vertical_lines.any? do |v_line|
v_x_avg = v_line.x + (v_line.w / 2)
v_y_min = v_line.y
v_y_max = v_line.y + v_line.h
h_x_min_expanded = h_x_min - line_thickness
h_x_max_expanded = h_x_max + line_thickness
h_y_min_expanded = h_y_avg - line_thickness
h_y_max_expanded = h_y_avg + line_thickness
v_x_min_expanded = v_x_avg - line_thickness
v_x_max_expanded = v_x_avg + line_thickness
v_y_min_expanded = v_y_min - line_thickness
v_y_max_expanded = v_y_max + line_thickness
x_overlap = v_x_min_expanded <= h_x_max_expanded && v_x_max_expanded >= h_x_min_expanded
y_overlap = h_y_min_expanded <= v_y_max_expanded && h_y_max_expanded >= v_y_min_expanded
x_overlap && y_overlap
end
end
node_index = 0
horizontal_lines = horizontal_lines.reject do |line|
nodes = []
loop do
node = page.text_nodes[node_index += 1]
break unless node
break if node.y > line.y
next if node.x + node.w < line.x || line.x + line.w < node.x ||
node.y + node.h < line.y - node.h || line.y < node.y
nodes << node
next if nodes.blank?
next_node = page.text_nodes[node_index + 1]
2025-11-03 17:00:52 +02:00
break unless next_node
2025-10-28 10:52:37 +02:00
break if next_node.x + next_node.w < line.x || line.x + line.w < next_node.x ||
next_node.y + next_node.h < line.y - next_node.h || line.y < next_node.y
end
next if nodes.blank?
width = nodes.last.x + nodes.last.w - nodes.first.x
next true if width > line.w / 2.0
end
horizontal_lines.each do |line|
line.h += 4 * line_thickness
line.y -= 4 * line_thickness
end
end
def extract_text_fields_from_page(page)
text_nodes = page.text_nodes
field_boxes = []
i = 0
while i < text_nodes.length
node = text_nodes[i]
next i += 1 if node.content != '_'
x1 = node.x
y1 = node.y
2025-11-29 20:13:00 +02:00
x2 = node.endx
y2 = node.endy
2025-10-28 10:52:37 +02:00
underscore_count = 1
j = i + 1
while j < text_nodes.length
next_node = text_nodes[j]
break unless next_node.content == '_'
distance = next_node.x - x2
height_diff = (next_node.y - y1).abs
break if distance > 0.02 || height_diff > node.h * 0.5
underscore_count += 1
2025-11-29 20:13:00 +02:00
next_x2 = next_node.endx
next_y2 = next_node.endy
2025-10-28 10:52:37 +02:00
x2 = next_x2
y2 = [y2, next_y2].max
y1 = [y1, next_node.y].min
j += 1
end
field_boxes << TextFieldBox.new(x: x1, y: y1, w: x2 - x1, h: y2 - y1) if underscore_count >= 2
i = j
end
field_boxes
end
def calculate_iou(box1, box2)
x1 = [box1.x, box2.x].max
y1 = [box1.y, box2.y].max
2025-11-29 20:13:00 +02:00
x2 = [box1.endx, box2.endx].min
y2 = [box1.endy, box2.endy].min
2025-10-28 10:52:37 +02:00
intersection_width = [0, x2 - x1].max
intersection_height = [0, y2 - y1].max
intersection_area = intersection_width * intersection_height
return 0.0 if intersection_area.zero?
box1_area = box1.w * box1.h
box2_area = box2.w * box2.h
union_area = box1_area + box2_area - intersection_area
intersection_area / union_area
end
def boxes_overlap?(box1, box2)
2025-11-29 20:13:00 +02:00
!(box1.endx < box2.x || box2.endx < box1.x || box1.endy < box2.y || box2.endy < box1.y)
2025-10-28 10:52:37 +02:00
end
2026-01-03 16:45:05 +02:00
def increase_confidence_for_overlapping_fields(image_fields, text_fields, confidence: 1, by: 1.0)
2025-10-28 10:52:37 +02:00
return image_fields if text_fields.blank?
image_fields.map do |image_field|
2026-01-03 16:45:05 +02:00
next if image_field.confidence >= confidence
2025-10-28 10:52:37 +02:00
next if image_field.type != 'text'
text_fields.each do |text_field|
2025-11-29 20:13:00 +02:00
break if text_field.y > image_field.endy
2025-10-28 10:52:37 +02:00
2025-11-29 20:13:00 +02:00
next if text_field.endy < image_field.y
2025-10-28 10:52:37 +02:00
2025-11-25 13:43:35 +02:00
next unless boxes_overlap?(image_field, text_field)
next if calculate_iou(image_field, text_field) < 0.4
2025-10-28 10:52:37 +02:00
break image_field.confidence += by
end
end
image_fields
end
2025-11-08 22:45:41 +02:00
# rubocop:enable Metrics, Style
2025-10-28 10:52:37 +02:00
end
end