diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb index b8ffa5ff..bbd7731f 100644 --- a/app/controllers/api/submissions_controller.rb +++ b/app/controllers/api/submissions_controller.rb @@ -87,6 +87,33 @@ module Api render json: { error: e.message }, status: :unprocessable_content end + def create_from_pdf + authorize!(:create, Submission) + authorize!(:create, Template.new(account: current_account, author: current_user)) + + params[:send_email] = true unless params.key?(:send_email) + params[:send_sms] = false unless params.key?(:send_sms) + + submission = Submissions::CreateFromPdf.call(user: current_user, params: params.to_unsafe_h) + submitters = submission.submitters.preload(attachments_attachments: :blob) + expires_at = Accounts.link_expires_at(current_account) + + json = Submissions::SerializeForApi.call(submission, submitters, params.merge(include: 'fields'), + with_documents: false, expires_at: expires_at) + json['schema'] = submission.template_schema + json['submitters'] = submitters.map do |submitter| + Submitters::SerializeForApi.call(submitter, with_documents: false, with_urls: true, params: params, + expires_at: expires_at) + end + + render json: json + rescue Submissions::CreateFromPdf::Error, Submitters::NormalizeValues::BaseError, + Submissions::CreateFromSubmitters::BaseError => e + Rollbar.warning(e) if defined?(Rollbar) + + render json: { error: e.message }, status: :unprocessable_content + end + def destroy if params[:permanently].in?(['true', true]) @submission.destroy! diff --git a/config/routes.rb b/config/routes.rb index 60afd92b..0dfef7ea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,6 +32,7 @@ Rails.application.routes.draw do resources :submissions, only: %i[index show create destroy] do resources :documents, only: %i[index], controller: 'submission_documents' collection do + post :pdf, action: :create_from_pdf resources :init, only: %i[create], controller: 'submissions' resources :emails, only: %i[create], controller: 'submissions', as: :submissions_emails end @@ -68,6 +69,7 @@ Rails.application.routes.draw do resource :user_initials, only: %i[edit update destroy] resources :submissions_archived, only: %i[index], path: 'submissions/archived' resources :submissions, only: %i[index], controller: 'submissions_dashboard' + post '/submissions/pdf', to: 'api/submissions#create_from_pdf', defaults: { format: :json } resources :submissions, only: %i[show destroy] do resources :unarchive, only: %i[create], controller: 'submissions_unarchive' resources :events, only: %i[index], controller: 'submission_events' diff --git a/lib/submissions/create_from_pdf.rb b/lib/submissions/create_from_pdf.rb new file mode 100644 index 00000000..09678122 --- /dev/null +++ b/lib/submissions/create_from_pdf.rb @@ -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 diff --git a/lib/templates/find_text_tags.rb b/lib/templates/find_text_tags.rb new file mode 100644 index 00000000..7c9f45e8 --- /dev/null +++ b/lib/templates/find_text_tags.rb @@ -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 diff --git a/lib/templates/remove_text_tags.rb b/lib/templates/remove_text_tags.rb new file mode 100644 index 00000000..3cb5936f --- /dev/null +++ b/lib/templates/remove_text_tags.rb @@ -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 diff --git a/spec/requests/submissions_spec.rb b/spec/requests/submissions_spec.rb index 82698613..5e04e42c 100644 --- a/spec/requests/submissions_spec.rb +++ b/spec/requests/submissions_spec.rb @@ -214,6 +214,47 @@ describe 'Submission API' do end end + describe 'POST /api/submissions/pdf' do + it 'creates a one-off submission from a PDF with explicit fields' do + file = Base64.strict_encode64(Rails.root.join('spec/fixtures/sample-document.pdf').binread) + + post '/api/submissions/pdf', headers: { 'x-auth-token': author.access_token.token }, params: { + name: 'Custom contract', + send_email: false, + documents: [ + { + name: 'contract', + file: file, + fields: [ + { + name: 'Signature', + type: 'signature', + role: 'Signer', + areas: [{ page: 1, x: 0.1, y: 0.2, w: 0.3, h: 0.1 }] + } + ] + } + ], + submitters: [{ role: 'Signer', email: 'john.doe@example.com' }] + }.to_json + + expect(response).to have_http_status(:ok) + + submission = Submission.last + + expect(submission.template_id).to be_nil + expect(submission.name).to eq('Custom contract') + expect(submission.documents_attachments.size).to eq(1) + expect(submission.template_schema.first['name']).to eq('contract') + expect(submission.template_fields.first['name']).to eq('Signature') + expect(submission.template_fields.first['areas'].first['page']).to eq(0) + expect(response.parsed_body['id']).to eq(submission.id) + expect(response.parsed_body['schema']).to eq(JSON.parse(submission.template_schema.to_json)) + expect(response.parsed_body['fields']).to eq(JSON.parse(submission.template_fields.to_json)) + expect(response.parsed_body['submitters'].first['embed_src']).to be_present + end + end + describe 'POST /api/submissions/emails' do it 'creates a submission using email' do post '/api/submissions/emails', headers: { 'x-auth-token': author.access_token.token }, params: {