mirror of https://github.com/docusealco/docuseal
parent
e7784a07b5
commit
c843b8ef75
@ -0,0 +1,269 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Submissions
|
||||||
|
module CreateFromPdf
|
||||||
|
PDF_CONTENT_TYPE = 'application/pdf'
|
||||||
|
BASE64_PDF_REGEXP = %r{\Adata:application/pdf(?:;[^,]+)?;base64,}i
|
||||||
|
URL_REGEXP = %r{\Ahttps?://}i
|
||||||
|
|
||||||
|
Error = Class.new(StandardError)
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def call(user:, params:)
|
||||||
|
template = nil
|
||||||
|
attrs = params.to_h.with_indifferent_access
|
||||||
|
|
||||||
|
raise Error, 'documents are required' if attrs[:documents].blank?
|
||||||
|
raise Error, 'submitters are required' if attrs[:submitters].blank?
|
||||||
|
raise Error, 'template_ids are not supported by this endpoint yet' if attrs[:template_ids].present?
|
||||||
|
|
||||||
|
template = build_template(user, attrs)
|
||||||
|
documents_attrs = sorted_documents_attrs(attrs[:documents])
|
||||||
|
documents_info = attach_documents(template, documents_attrs, attrs)
|
||||||
|
documents = documents_info.pluck(:attachment)
|
||||||
|
|
||||||
|
template.schema = documents.map.with_index do |document, index|
|
||||||
|
{
|
||||||
|
attachment_uuid: document.uuid,
|
||||||
|
name: documents_attrs[index][:name].presence || document.filename.base
|
||||||
|
}
|
||||||
|
end
|
||||||
|
template.submitters = build_template_submitters(attrs)
|
||||||
|
template.fields = build_fields(template, documents_info)
|
||||||
|
|
||||||
|
raise Error, 'PDF does not contain fields' if template.fields.blank?
|
||||||
|
|
||||||
|
template.save!
|
||||||
|
|
||||||
|
submissions = Submissions.create_from_submitters(
|
||||||
|
template: template,
|
||||||
|
user: user,
|
||||||
|
source: :api,
|
||||||
|
with_template: false,
|
||||||
|
submitters_order: attrs[:order] || attrs[:submitters_order] ||
|
||||||
|
Submissions::DEFAULT_SUBMITTERS_ORDER,
|
||||||
|
submissions_attrs: [submission_attrs(attrs)]
|
||||||
|
)
|
||||||
|
|
||||||
|
submissions.each { |submission| clone_documents_to_submission(template, submission) }
|
||||||
|
|
||||||
|
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||||
|
Submissions.send_signature_requests(submissions)
|
||||||
|
SearchEntries.enqueue_reindex(submissions)
|
||||||
|
|
||||||
|
template.update!(archived_at: Time.current)
|
||||||
|
|
||||||
|
submissions.first
|
||||||
|
rescue Templates::CreateAttachments::PdfEncrypted
|
||||||
|
raise Error, 'PDF encrypted'
|
||||||
|
rescue DownloadUtils::UnableToDownload => e
|
||||||
|
raise Error, e.message
|
||||||
|
rescue HexaPDF::Error, Pdfium::PdfiumError => e
|
||||||
|
raise Error, "Invalid PDF: #{e.message}"
|
||||||
|
ensure
|
||||||
|
archive_template(template) if template&.persisted? && template.archived_at.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_template(user, attrs)
|
||||||
|
Template.create!(
|
||||||
|
account: user.account,
|
||||||
|
author: user,
|
||||||
|
folder: user.account.default_template_folder,
|
||||||
|
name: attrs[:name].presence || attrs.dig(:documents, 0, :name).presence || SecureRandom.uuid,
|
||||||
|
source: :api,
|
||||||
|
fields: [],
|
||||||
|
schema: [],
|
||||||
|
submitters: []
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def attach_documents(template, documents_attrs, params)
|
||||||
|
documents_attrs.map do |document_attrs|
|
||||||
|
file, text_tags = build_uploaded_file(document_attrs, remove_tags: remove_tags?(params))
|
||||||
|
attachment =
|
||||||
|
Templates::CreateAttachments.handle_pdf_or_image(template, file, file.read, params, extract_fields: true)
|
||||||
|
|
||||||
|
text_fields = text_tags.map do |tag|
|
||||||
|
field = tag[:field].deep_dup
|
||||||
|
|
||||||
|
field['areas'].each { |area| area['attachment_uuid'] = attachment.uuid }
|
||||||
|
field
|
||||||
|
end
|
||||||
|
|
||||||
|
{ attachment: attachment, text_fields: text_fields, attrs: document_attrs }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_tags?(params)
|
||||||
|
!params[:remove_tags].in?([false, 'false', '0', 0])
|
||||||
|
end
|
||||||
|
|
||||||
|
def sorted_documents_attrs(documents_attrs)
|
||||||
|
documents_attrs.map(&:with_indifferent_access)
|
||||||
|
.sort_by.with_index { |item, index| item[:position].presence || index }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_uploaded_file(document_attrs, remove_tags: true)
|
||||||
|
name = document_attrs[:name].presence || 'document.pdf'
|
||||||
|
filename = name.ends_with?('.pdf') ? name : "#{name}.pdf"
|
||||||
|
data = read_document_data(document_attrs[:file])
|
||||||
|
text_tags = Templates::FindTextTags.call(data)
|
||||||
|
data = Templates::RemoveTextTags.call(data, text_tags) if remove_tags
|
||||||
|
|
||||||
|
tempfile = Tempfile.new(['docuseal-submission-pdf', '.pdf'])
|
||||||
|
tempfile.binmode
|
||||||
|
tempfile.write(data)
|
||||||
|
tempfile.rewind
|
||||||
|
|
||||||
|
file = ActionDispatch::Http::UploadedFile.new(
|
||||||
|
tempfile: tempfile,
|
||||||
|
filename: filename,
|
||||||
|
type: PDF_CONTENT_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
|
[file, text_tags]
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_document_data(value)
|
||||||
|
raise Error, 'documents[].file is required' if value.blank?
|
||||||
|
|
||||||
|
if value.to_s.match?(URL_REGEXP)
|
||||||
|
DownloadUtils.call(value, validate: true).body
|
||||||
|
else
|
||||||
|
Base64.strict_decode64(value.to_s.sub(BASE64_PDF_REGEXP, ''))
|
||||||
|
end
|
||||||
|
rescue ArgumentError
|
||||||
|
raise Error, 'documents[].file should be a PDF URL or base64 encoded PDF'
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_template_submitters(attrs)
|
||||||
|
attrs[:submitters].map.with_index do |submitter_attrs, index|
|
||||||
|
{
|
||||||
|
'name' => submitter_attrs[:role].presence || submitter_attrs[:name].presence ||
|
||||||
|
default_submitter_name(index),
|
||||||
|
'uuid' => SecureRandom.uuid,
|
||||||
|
'order' => submitter_attrs[:order]
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_submitter_name(index)
|
||||||
|
name = %w[First Second Third Fourth Fifth Sixth Seventh Eighth Ninth Tenth][index]
|
||||||
|
|
||||||
|
name ? "#{name} Party" : "Party #{index + 1}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_fields(template, documents_info)
|
||||||
|
acro_fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents_info.pluck(:attachment))
|
||||||
|
acro_fields_index = acro_fields.group_by { |field| field['areas'].to_a.first&.dig('attachment_uuid') }
|
||||||
|
|
||||||
|
documents_info.flat_map do |info|
|
||||||
|
attachment = info[:attachment]
|
||||||
|
explicit_fields = Array.wrap(info[:attrs][:fields]).map do |field_attrs|
|
||||||
|
build_field(field_attrs.with_indifferent_access, attachment, template)
|
||||||
|
end
|
||||||
|
|
||||||
|
if explicit_fields.present?
|
||||||
|
explicit_fields
|
||||||
|
elsif info[:text_fields].present?
|
||||||
|
assign_tag_submitters(info[:text_fields], template)
|
||||||
|
else
|
||||||
|
acro_fields_index[attachment.uuid].to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_field(field_attrs, document, template)
|
||||||
|
raise Error, 'fields[].areas are required' if field_attrs[:areas].blank?
|
||||||
|
|
||||||
|
role = field_attrs[:role].presence
|
||||||
|
submitter = template.submitters.find { |item| item['name'].to_s.casecmp?(role.to_s) } ||
|
||||||
|
template.submitters.first
|
||||||
|
options = build_options(field_attrs[:options])
|
||||||
|
|
||||||
|
{
|
||||||
|
'uuid' => field_attrs[:uuid].presence || SecureRandom.uuid,
|
||||||
|
'submitter_uuid' => submitter['uuid'],
|
||||||
|
'name' => field_attrs[:name].to_s,
|
||||||
|
'type' => field_attrs[:type].presence || 'text',
|
||||||
|
'required' => field_attrs.key?(:required) ? field_attrs[:required] : true,
|
||||||
|
'readonly' => field_attrs[:readonly],
|
||||||
|
'title' => field_attrs[:title],
|
||||||
|
'description' => field_attrs[:description],
|
||||||
|
'preferences' => field_attrs[:preferences] || {},
|
||||||
|
'validation' => field_attrs[:validation],
|
||||||
|
'options' => options,
|
||||||
|
'areas' => Array.wrap(field_attrs[:areas]).map do |area|
|
||||||
|
build_area(area.with_indifferent_access, document, options)
|
||||||
|
end
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def assign_tag_submitters(fields, template)
|
||||||
|
fields.map do |field|
|
||||||
|
role = field.delete('role').presence
|
||||||
|
submitter = template.submitters.find { |item| item['name'].to_s.casecmp?(role.to_s) } ||
|
||||||
|
template.submitters.first
|
||||||
|
|
||||||
|
field.merge('submitter_uuid' => submitter['uuid'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_options(options)
|
||||||
|
Array.wrap(options).filter_map do |option|
|
||||||
|
if option.is_a?(Hash)
|
||||||
|
{
|
||||||
|
'value' => option[:value] || option['value'],
|
||||||
|
'uuid' => option[:uuid] || option['uuid'] || SecureRandom.uuid
|
||||||
|
}
|
||||||
|
elsif option.present?
|
||||||
|
{ 'value' => option, 'uuid' => SecureRandom.uuid }
|
||||||
|
end
|
||||||
|
end.presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_area(area_attrs, document, options)
|
||||||
|
page = area_attrs[:page].to_i
|
||||||
|
option = options&.find { |item| item['value'] == area_attrs[:option] }
|
||||||
|
|
||||||
|
{
|
||||||
|
'x' => area_attrs[:x].to_f,
|
||||||
|
'y' => area_attrs[:y].to_f,
|
||||||
|
'w' => area_attrs[:w].to_f,
|
||||||
|
'h' => area_attrs[:h].to_f,
|
||||||
|
'cell_w' => area_attrs[:cell_w],
|
||||||
|
'option_uuid' => area_attrs[:option_uuid] || option&.dig('uuid'),
|
||||||
|
'attachment_uuid' => document.uuid,
|
||||||
|
'page' => page.positive? ? page - 1 : 0
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def submission_attrs(attrs)
|
||||||
|
attrs.slice(:send_email, :send_sms, :bcc_completed, :completed_redirect_url, :reply_to,
|
||||||
|
:expire_at, :name, :message, :variables).merge(
|
||||||
|
submitters: attrs[:submitters]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clone_documents_to_submission(template, submission)
|
||||||
|
template.schema_documents.each do |document|
|
||||||
|
new_document = submission.documents_attachments.create!(
|
||||||
|
uuid: document.uuid,
|
||||||
|
blob_id: document.blob_id
|
||||||
|
)
|
||||||
|
|
||||||
|
Templates::CloneAttachments.clone_document_preview_images_attachments(
|
||||||
|
document: document,
|
||||||
|
new_document: new_document
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def archive_template(template)
|
||||||
|
template.update_column(:archived_at, Time.current)
|
||||||
|
rescue StandardError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,141 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Templates
|
||||||
|
module FindTextTags
|
||||||
|
TAG_REGEXP = /\{\{([^{}]+)\}\}/
|
||||||
|
TRUE_VALUES = [true, 'true', '1', 1].freeze
|
||||||
|
FALSE_VALUES = [false, 'false', '0', 0].freeze
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def call(data)
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
Pdfium::Document.open_bytes(data) do |doc|
|
||||||
|
doc.page_count.times do |page_index|
|
||||||
|
page = doc.get_page(page_index)
|
||||||
|
|
||||||
|
tags.concat(extract_page_tags(page, page_index))
|
||||||
|
ensure
|
||||||
|
page&.close
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
tags
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_page_tags(page, page_index)
|
||||||
|
group_text_nodes_by_line(page.text_nodes).flat_map do |line_nodes|
|
||||||
|
text = line_nodes.map(&:content).join
|
||||||
|
|
||||||
|
text.to_enum(:scan, TAG_REGEXP).filter_map do
|
||||||
|
match = Regexp.last_match
|
||||||
|
tag_nodes = line_nodes[match.begin(0)...match.end(0)]
|
||||||
|
|
||||||
|
next if tag_nodes.blank?
|
||||||
|
|
||||||
|
attrs = parse_tag(match[1])
|
||||||
|
|
||||||
|
next if attrs.blank?
|
||||||
|
|
||||||
|
area = build_area(tag_nodes, page_index)
|
||||||
|
|
||||||
|
{
|
||||||
|
field: build_field(attrs, area),
|
||||||
|
area: area
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_text_nodes_by_line(text_nodes)
|
||||||
|
text_nodes.each_with_object([]) do |node, lines|
|
||||||
|
line = lines.find { |items| same_line?(items.first, node) }
|
||||||
|
|
||||||
|
if line
|
||||||
|
line << node
|
||||||
|
else
|
||||||
|
lines << [node]
|
||||||
|
end
|
||||||
|
end.map { |line| line.sort_by(&:x) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def same_line?(a, b)
|
||||||
|
(a.endy - b.endy).abs < [a.h, b.h].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_tag(text)
|
||||||
|
parts = text.split(';').map(&:strip).compact_blank
|
||||||
|
name = parts.shift
|
||||||
|
|
||||||
|
return if name.blank?
|
||||||
|
|
||||||
|
attrs = { 'name' => name }
|
||||||
|
|
||||||
|
parts.each do |part|
|
||||||
|
key, value = part.split('=', 2).map(&:strip)
|
||||||
|
|
||||||
|
next if key.blank?
|
||||||
|
|
||||||
|
attrs[key.tr('-', '_').downcase] = cast_value(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
def cast_value(value)
|
||||||
|
return true if value.nil?
|
||||||
|
return true if TRUE_VALUES.include?(value)
|
||||||
|
return false if FALSE_VALUES.include?(value)
|
||||||
|
|
||||||
|
value
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_area(nodes, page_index)
|
||||||
|
x1 = nodes.map(&:x).min
|
||||||
|
y1 = nodes.map(&:y).min
|
||||||
|
x2 = nodes.map(&:endx).max
|
||||||
|
y2 = nodes.map(&:endy).max
|
||||||
|
|
||||||
|
{
|
||||||
|
'x' => x1,
|
||||||
|
'y' => y1,
|
||||||
|
'w' => x2 - x1,
|
||||||
|
'h' => y2 - y1,
|
||||||
|
'page' => page_index
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_field(attrs, area)
|
||||||
|
preferences = build_preferences(attrs)
|
||||||
|
|
||||||
|
{
|
||||||
|
'uuid' => attrs['uuid'].presence || SecureRandom.uuid,
|
||||||
|
'name' => attrs['name'],
|
||||||
|
'role' => attrs['role'],
|
||||||
|
'type' => attrs['type'].presence || 'text',
|
||||||
|
'required' => attrs.key?('required') ? attrs['required'] : true,
|
||||||
|
'readonly' => attrs['readonly'],
|
||||||
|
'title' => attrs['title'],
|
||||||
|
'description' => attrs['description'],
|
||||||
|
'default_value' => attrs['default_value'] || attrs['value'],
|
||||||
|
'preferences' => preferences,
|
||||||
|
'options' => build_options(attrs),
|
||||||
|
'areas' => [area]
|
||||||
|
}.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_preferences(attrs)
|
||||||
|
attrs.slice('font_size', 'font_type', 'font', 'color', 'background', 'align', 'valign', 'format',
|
||||||
|
'price', 'currency', 'mask', 'reasons').compact_blank
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_options(attrs)
|
||||||
|
values = attrs['options'] || attrs['values']
|
||||||
|
|
||||||
|
values.to_s.split(',').map(&:strip).compact_blank.map do |value|
|
||||||
|
{ 'value' => value, 'uuid' => SecureRandom.uuid }
|
||||||
|
end.presence
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Templates
|
||||||
|
module RemoveTextTags
|
||||||
|
PADDING = 0.002
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def call(data, tags)
|
||||||
|
return data if tags.blank?
|
||||||
|
|
||||||
|
pdf = HexaPDF::Document.new(io: StringIO.new(data))
|
||||||
|
|
||||||
|
tags.group_by { |tag| tag[:area]['page'] }.each do |page_index, page_tags|
|
||||||
|
page = pdf.pages[page_index]
|
||||||
|
|
||||||
|
next unless page
|
||||||
|
|
||||||
|
canvas = page.canvas(type: :overlay)
|
||||||
|
box = page.box
|
||||||
|
|
||||||
|
page_tags.each do |tag|
|
||||||
|
area = tag[:area]
|
||||||
|
x = [(area['x'] - PADDING) * box.width, 0].max
|
||||||
|
y = box.height - ((area['y'] + area['h'] + PADDING) * box.height)
|
||||||
|
w = [(area['w'] + (PADDING * 2)) * box.width, box.width - x].min
|
||||||
|
h = (area['h'] + (PADDING * 2)) * box.height
|
||||||
|
|
||||||
|
canvas.fill_color('white').rectangle(x, y, w, h).fill
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
io = StringIO.new
|
||||||
|
|
||||||
|
pdf.write(io, incremental: false, validate: false)
|
||||||
|
|
||||||
|
io.string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in new issue