@@ -30,7 +30,7 @@
<% end %>
<% end %>
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
-
+
<%= svg_icon('download', class: 'w-6 h-6') %>
<%= t('download_documents') %>
@@ -50,5 +50,5 @@
<% end %>
-
+
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
diff --git a/app/views/submit_form/email_2fa.html.erb b/app/views/submit_form/email_2fa.html.erb
index 74d2e52b..319a3c80 100644
--- a/app/views/submit_form/email_2fa.html.erb
+++ b/app/views/submit_form/email_2fa.html.erb
@@ -2,7 +2,7 @@
<% I18n.with_locale(@submitter.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %>
<% end %>
-
+
@@ -46,7 +46,7 @@
<% end %>
-
+
@@ -54,7 +54,7 @@
<%= f.button button_title(title: t('submit')), class: 'base-button' %>
<% end %>
- <%= button_to t(:re_send_email), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug, resend: true }, method: :put, id: 'resend_code', class: 'hidden' %>
+ <%= button_to t(:re_send_email), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug, resend: true }, method: :put, form: { id: 'resend_code_form', class: 'hidden' } %>
<% else %>
<% if params[:t] %>
@@ -69,4 +69,4 @@
<%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %>
<% end %>
-
+
diff --git a/app/views/submit_form/show.html.erb b/app/views/submit_form/show.html.erb
index 949f6d1d..67c49999 100644
--- a/app/views/submit_form/show.html.erb
+++ b/app/views/submit_form/show.html.erb
@@ -8,28 +8,33 @@
<% page_blob_struct = Struct.new(:url, :metadata) %>
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<% font_scale = 1000.0 / PdfUtils::US_LETTER_W %>
-<% decline_modal_checkbox_uuid = nil %>
-<% delegate_modal_checkbox_uuid = nil %>
+<% decline_modal_id = nil %>
+<% delegate_modal_id = nil %>
-
<% if @form_configs[:with_decline] %>
- <%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_checkbox_uuid do %>
+ <%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_id do %>
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
<% end %>
<% if @form_configs[:with_delegate] %>
- <%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_checkbox_uuid do %>
+ <%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_id do %>
<%= render 'submit_form/delegate_form', submitter: @submitter %>
<% end %>
<% end %>
diff --git a/app/views/templates_share_link/show.html.erb b/app/views/templates_share_link/show.html.erb
index b654c8d7..910559b4 100644
--- a/app/views/templates_share_link/show.html.erb
+++ b/app/views/templates_share_link/show.html.erb
@@ -23,7 +23,12 @@
<% end %>
-
+
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
diff --git a/app/views/templates_share_link_qr/_branding.html.erb b/app/views/templates_share_link_qr/_branding.html.erb
new file mode 100644
index 00000000..47822ac5
--- /dev/null
+++ b/app/views/templates_share_link_qr/_branding.html.erb
@@ -0,0 +1,2 @@
+<%= t('powered_by') %>
+
<%= Docuseal.product_name %>
diff --git a/app/views/templates_share_link_qr/_logo.html.erb b/app/views/templates_share_link_qr/_logo.html.erb
new file mode 100644
index 00000000..067e3227
--- /dev/null
+++ b/app/views/templates_share_link_qr/_logo.html.erb
@@ -0,0 +1,2 @@
+<%= render 'shared/logo' %>
+
<%= Docuseal.product_name %>
diff --git a/app/views/templates_share_link_qr/disabled.html.erb b/app/views/templates_share_link_qr/disabled.html.erb
new file mode 100644
index 00000000..ef1f929c
--- /dev/null
+++ b/app/views/templates_share_link_qr/disabled.html.erb
@@ -0,0 +1,10 @@
+
+
+ <%= t('share_link_is_currently_disabled') %>
+
+ <% if can?(:update, @template) %>
+
+ <%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: template_share_link_qr_path(@template) }, method: :post, data: { turbo: false }, class: 'base-button w-full' %>
+
+ <% end %>
+
diff --git a/app/views/templates_share_link_qr/show.html.erb b/app/views/templates_share_link_qr/show.html.erb
new file mode 100644
index 00000000..322be0c2
--- /dev/null
+++ b/app/views/templates_share_link_qr/show.html.erb
@@ -0,0 +1,288 @@
+
+<% page_width_css = @page_size == 'Letter' ? 8.5 * 96.0 : 210.0 * 96.0 / 25.4 %>
+<% page_height_css = @page_size == 'Letter' ? 11.0 * 96.0 : 297.0 * 96.0 / 25.4 %>
+<% page_width = @page_size == 'Letter' ? '8.5in' : '210mm' %>
+<% page_cqw = ->(px) { format('%.6fcqw', px / page_width_css * 100.0) } %>
+
+
+
+
+
<%= @template.name %>
+
+
+
+
+
+
+ <%= render 'logo' %>
+
+
+
+
+ <%== @qr_svg_code %>
+
+
+
+
+ <%= render 'branding' %>
+
+
+
+
+
+
+
diff --git a/app/views/testing_api_settings/index.html.erb b/app/views/testing_api_settings/index.html.erb
index 956aa970..a385139d 100644
--- a/app/views/testing_api_settings/index.html.erb
+++ b/app/views/testing_api_settings/index.html.erb
@@ -8,11 +8,21 @@
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
- <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
- <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
-
- <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %>
- <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
-
+ <% if @webhook_url.new_record? %>
+ <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
+ <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
+
+ <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %>
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
+
+ <% end %>
+ <% else %>
+ <%= form_for @webhook_url, url: settings_webhook_path(@webhook_url), method: :put, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
+ <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
+
+ <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook', required: true %>
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index da006683..37e6f448 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -162,6 +162,7 @@ en: &en
download_documents: Download documents
downloading: Downloading
download: Download
+ page: Page
decline: Decline
declined: Declined
delegate: Delegate
@@ -367,6 +368,9 @@ en: &en
sign_out: Sign out
page_number: 'Page %{number}'
powered_by: Powered by
+ qr_code: QR Code
+ print: Print
+ scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan the QR code above with your phone camera to open and sign this document.
count_documents_signed_with_html: '
%{count} documents signed with'
storage: Storage
notifications: Notifications
@@ -1201,6 +1205,7 @@ es: &es
download_documents: Descargar documentos
downloading: Descargando
download: Descargar
+ page: Página
decline: Rechazar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Ingrese la dirección de correo electrónico de la persona a quien desea delegar
@@ -1406,6 +1411,9 @@ es: &es
sign_out: Cerrar sesión
page_number: 'Página %{number}'
powered_by: Desarrollado por
+ qr_code: Código QR
+ print: Imprimir
+ scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escanea el código QR de arriba con la cámara de tu teléfono para abrir y firmar este documento.
count_documents_signed_with_html: '
%{count} documentos firmados con'
storage: Almacenamiento
notifications: Notificaciones
@@ -2237,6 +2245,7 @@ it: &it
download_documents: Scarica documenti
downloading: Scaricamento
download: Scarica
+ page: Pagina
decline: Rifiuta
delegate: Delega
enter_the_email_address_of_the_person_you_want_to_delegate_to: Inserisci l'indirizzo email della persona a cui vuoi delegare
@@ -2442,6 +2451,9 @@ it: &it
sign_out: Esci
page_number: 'Pagina %{number}'
powered_by: Fornito da
+ qr_code: Codice QR
+ print: Stampa
+ scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scansiona il codice QR qui sopra con la fotocamera del tuo telefono per aprire e firmare questo documento.
count_documents_signed_with_html: '
%{count} documenti firmati con'
storage: Archiviazione
notifications: Notifiche
@@ -3274,6 +3286,7 @@ fr: &fr
download_documents: Télécharger les documents
downloading: Téléchargement
download: Télécharger
+ page: Page
decline: Refuser
delegate: Déléguer
enter_the_email_address_of_the_person_you_want_to_delegate_to: Saisissez l'adresse e-mail de la personne à qui vous souhaitez déléguer
@@ -3479,6 +3492,9 @@ fr: &fr
sign_out: Se déconnecter
page_number: Page %{number}
powered_by: Propulsé par
+ qr_code: Code QR
+ print: Imprimer
+ scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannez le code QR ci-dessus avec l'appareil photo de votre téléphone pour ouvrir et signer ce document.
count_documents_signed_with_html: "
%{count} documents signés avec"
storage: Stockage
notifications: Notifications
@@ -4307,6 +4323,7 @@ pt: &pt
download_documents: Baixar documentos
downloading: Baixando
download: Baixar
+ page: Página
decline: Recusar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Insira o endereço de e-mail da pessoa para quem deseja delegar
@@ -4512,6 +4529,9 @@ pt: &pt
sign_out: Sair
page_number: 'Página %{number}'
powered_by: Desenvolvido por
+ qr_code: Código QR
+ print: Imprimir
+ scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escaneie o código QR acima com a câmera do seu telefone para abrir e assinar este documento.
count_documents_signed_with_html: '
%{count} documentos assinados com'
storage: Armazenamento
notifications: Notificações
@@ -5343,6 +5363,7 @@ de: &de
download_documents: Dokumente herunterladen
downloading: Wird heruntergeladen
download: Download
+ page: Seite
decline: Ablehnen
delegate: Delegieren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Geben Sie die E-Mail-Adresse der Person ein, an die Sie delegieren möchten
@@ -5548,6 +5569,9 @@ de: &de
sign_out: Abmelden
page_number: 'Seite %{number}'
powered_by: Bereitgestellt von
+ qr_code: QR-Code
+ print: Drucken
+ scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannen Sie den QR-Code oben mit Ihrer Handykamera, um dieses Dokument zu öffnen und zu unterzeichnen.
count_documents_signed_with_html: '
%{count} Dokumente signiert mit'
storage: Speicher
notifications: Benachrichtigungen
@@ -6243,6 +6267,7 @@ pl:
view: Widok
hi_there: Cześć,
download: Pobierz
+ page: Strona
decline: Odrzuć
delegate: Deleguj
enter_the_email_address_of_the_person_you_want_to_delegate_to: Wprowadź adres e-mail osoby, do której chcesz delegować
@@ -6343,6 +6368,7 @@ uk:
view: Переглянути
hi_there: Привіт,
download: Завантажити
+ page: Сторінка
decline: Відхилити
delegate: Делегувати
enter_the_email_address_of_the_person_you_want_to_delegate_to: Введіть адресу електронної пошти особи, якій ви хочете делегувати
@@ -6443,6 +6469,7 @@ cs:
view: Zobrazit
hi_there: Ahoj,
download: Stáhnout
+ page: Stránka
decline: Odmítnout
delegate: Delegovat
enter_the_email_address_of_the_person_you_want_to_delegate_to: Zadejte e-mailovou adresu osoby, na kterou chcete delegovat
@@ -6543,6 +6570,7 @@ he:
view: תצוגה
hi_there: שלום,
download: הורד
+ page: עמוד
decline: דחייה
delegate: הואלה
enter_the_email_address_of_the_person_you_want_to_delegate_to: הזן את כתובת הדוא"ל של האדם שברצונך להאציל אליו
@@ -6780,6 +6808,7 @@ nl: &nl
download_documents: Documenten downloaden
downloading: Downloaden
download: Downloaden
+ page: Pagina
decline: Weigeren
delegate: Delegeren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Voer het e-mailadres in van de persoon aan wie u wilt delegeren
@@ -6985,6 +7014,9 @@ nl: &nl
sign_out: Afmelden
page_number: Pagina %{number}
powered_by: Aangedreven door
+ qr_code: QR-code
+ print: Afdrukken
+ scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan de bovenstaande QR-code met je telefooncamera om dit document te openen en te ondertekenen.
count_documents_signed_with_html: "
%{count} documenten ondertekend met"
storage: Opslag
notifications: Meldingen
@@ -7676,6 +7708,7 @@ ar:
view: عرض
hi_there: مرحبا,
download: تحميل
+ page: صفحة
decline: رفض
delegate: تفويض
enter_the_email_address_of_the_person_you_want_to_delegate_to: أدخل عنوان البريد الإلكتروني للشخص الذي تريد التفويض إليه
@@ -7776,6 +7809,7 @@ ko:
view: 보기
hi_there: 안녕하세요,
download: 다운로드
+ page: 페이지
decline: 거절
delegate: 위임
enter_the_email_address_of_the_person_you_want_to_delegate_to: 위임할 사람의 이메일 주소를 입력하세요
@@ -7876,6 +7910,7 @@ ja:
view: 表示
hi_there: こんにちは
download: ダウンロード
+ page: ページ
decline: 辞退
delegate: 委任
enter_the_email_address_of_the_person_you_want_to_delegate_to: 委任したい人のメールアドレスを入力してください
diff --git a/config/routes.rb b/config/routes.rb
index f9d3ae38..60afd92b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -108,6 +108,7 @@ Rails.application.routes.draw do
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
resource :share_link, only: %i[show create], controller: 'templates_share_link'
+ resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
resources :recipients, only: %i[create], controller: 'templates_recipients'
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
resources :submissions_export, only: %i[index new]
@@ -149,6 +150,7 @@ Rails.application.routes.draw do
resources :decline, only: %i[create], controller: 'submit_form_decline'
resources :delegate, only: %i[create], controller: 'submit_form_delegate'
resources :invite, only: %i[create], controller: 'submit_form_invite'
+ resources :metadata, only: %i[index], controller: 'submit_form_metadata'
resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development?
get :completed
get :delegated
diff --git a/db/migrate/20260416100000_create_document_metadata.rb b/db/migrate/20260416100000_create_document_metadata.rb
new file mode 100644
index 00000000..e835692c
--- /dev/null
+++ b/db/migrate/20260416100000_create_document_metadata.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class CreateDocumentMetadata < ActiveRecord::Migration[8.1]
+ def change
+ create_table :document_metadata do |t|
+ t.references :account, null: false, foreign_key: true, index: false
+ t.string :blob_checksum, null: false
+ t.text :text_runs, null: false
+
+ t.datetime :created_at, null: false
+ end
+
+ add_index :document_metadata, %i[account_id blob_checksum], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 1112200d..e0ba7a4f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
+ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -168,6 +168,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end
+ create_table "document_metadata", force: :cascade do |t|
+ t.bigint "account_id", null: false
+ t.string "blob_checksum", null: false
+ t.datetime "created_at", null: false
+ t.text "text_runs", null: false
+ t.index ["account_id", "blob_checksum"], name: "index_document_metadata_on_account_id_and_blob_checksum", unique: true
+ end
+
create_table "dynamic_document_versions", force: :cascade do |t|
t.text "areas", null: false
t.datetime "created_at", null: false
@@ -553,6 +561,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
+ add_foreign_key "document_metadata", "accounts"
add_foreign_key "dynamic_document_versions", "dynamic_documents"
add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts"
diff --git a/lib/document_metadatas.rb b/lib/document_metadatas.rb
new file mode 100644
index 00000000..56582c4a
--- /dev/null
+++ b/lib/document_metadatas.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module DocumentMetadatas
+ module_function
+
+ def find_or_create_for_document(document, account_id:)
+ checksum = document.blob.checksum
+
+ metadata = DocumentMetadata.find_by(account_id:, blob_checksum: checksum)
+ metadata ||= DocumentMetadata.create!(account_id:, blob_checksum: checksum, text_runs: build_text_runs(document))
+
+ metadata
+ rescue ActiveRecord::RecordNotUnique
+ retry
+ end
+
+ def build_text_runs(document)
+ number_of_pages = document.metadata.dig('pdf', 'number_of_pages').to_i
+
+ return {} if number_of_pages.zero?
+
+ Pdfium::Document.open_bytes(document.download) do |doc|
+ (0...doc.page_count).each_with_object({}) do |page_index, acc|
+ page = doc.get_page(page_index)
+
+ acc[page_index] = page.text_objects.map do |node|
+ { text: node.content, x: node.x, y: node.y, w: node.w, h: node.h, font_size: node.font_size }
+ end
+ ensure
+ page&.close
+ end
+ end
+ end
+end
diff --git a/lib/mcp/handle_request.rb b/lib/mcp/handle_request.rb
index 7149fd06..83d6725f 100644
--- a/lib/mcp/handle_request.rb
+++ b/lib/mcp/handle_request.rb
@@ -4,6 +4,7 @@ module Mcp
module HandleRequest
TOOLS = [
Mcp::Tools::SearchTemplates,
+ Mcp::Tools::LoadTemplate,
Mcp::Tools::CreateTemplate,
Mcp::Tools::SendDocuments,
Mcp::Tools::SearchDocuments
diff --git a/lib/mcp/tools/create_template.rb b/lib/mcp/tools/create_template.rb
index 0a945264..e45ee66e 100644
--- a/lib/mcp/tools/create_template.rb
+++ b/lib/mcp/tools/create_template.rb
@@ -6,27 +6,22 @@ module Mcp
SCHEMA = {
name: 'create_template',
title: 'Create Template',
- description: 'Create a template from a PDF. Provide a URL or base64-encoded file content.',
+ description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
+ 'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
inputSchema: {
type: 'object',
properties: {
- url: {
- type: 'string',
- description: 'URL of the document file to upload'
- },
- file: {
- type: 'string',
- description: 'Base64-encoded file content'
- },
- filename: {
+ name: {
type: 'string',
- description: 'Filename with extension (required when using file)'
+ description: 'Template name (used as the template name and required when url is not provided)'
},
- name: {
+ url: {
type: 'string',
- description: 'Template name (defaults to filename)'
+ description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
+ 'created and the returned edit_url can be used to upload a file via the UI.'
}
- }
+ },
+ required: %w[name]
},
annotations: {
readOnlyHint: false,
@@ -44,48 +39,44 @@ module Mcp
account = current_user.account
- if arguments['file'].present?
- tempfile = Tempfile.new
- tempfile.binmode
- tempfile.write(Base64.decode64(arguments['file']))
- tempfile.rewind
+ template = Template.new(
+ account:,
+ author: current_user,
+ folder: account.default_template_folder,
+ name: arguments['name'].to_s.presence || 'New Template',
+ fields: [],
+ schema: []
+ )
- filename = arguments['filename'] || 'document.pdf'
- elsif arguments['url'].present?
+ if arguments['url'].present?
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body)
tempfile.rewind
filename = File.basename(URI.decode_www_form_component(arguments['url']))
- else
- return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true }
- end
- file = ActionDispatch::Http::UploadedFile.new(
- tempfile:,
- filename:,
- type: Marcel::MimeType.for(tempfile)
- )
+ file = ActionDispatch::Http::UploadedFile.new(
+ tempfile:,
+ filename:,
+ type: Marcel::MimeType.for(tempfile)
+ )
- template = Template.new(
- account:,
- author: current_user,
- folder: account.default_template_folder,
- name: arguments['name'].presence || File.basename(filename, '.*')
- )
+ template.name = arguments['name'].presence || File.basename(filename, '.*')
+ template.save!
- template.save!
+ documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
+ schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
- documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
- schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
+ if template.fields.blank?
+ template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
+ end
- if template.fields.blank?
- template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
+ template.update!(schema:)
+ else
+ template.save!
end
- template.update!(schema:)
-
WebhookUrls.enqueue_events(template, 'template.created')
SearchEntries.enqueue_reindex(template)
@@ -104,7 +95,7 @@ module Mcp
]
}
end
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
end
diff --git a/lib/mcp/tools/load_template.rb b/lib/mcp/tools/load_template.rb
new file mode 100644
index 00000000..7cfcc3ff
--- /dev/null
+++ b/lib/mcp/tools/load_template.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Mcp
+ module Tools
+ module LoadTemplate
+ SCHEMA = {
+ name: 'load_template',
+ title: 'Load Template',
+ description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ template_id: {
+ type: 'integer',
+ description: 'Template identifier'
+ }
+ },
+ required: %w[template_id]
+ },
+ annotations: {
+ readOnlyHint: true,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false
+ }
+ }.freeze
+
+ module_function
+
+ def call(arguments, _current_user, current_ability)
+ template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
+
+ return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
+
+ current_ability.authorize!(:read, template)
+
+ submitters_index = template.submitters.index_by { |s| s['uuid'] }
+
+ roles = template.submitters.pluck('name')
+
+ fields = template.fields.filter_map do |field|
+ next if field['name'].blank?
+
+ {
+ name: field['name'],
+ type: field['type'],
+ role: submitters_index[field['submitter_uuid']]&.dig('name')
+ }
+ end
+
+ {
+ content: [
+ {
+ type: 'text',
+ text: {
+ id: template.id,
+ name: template.name,
+ roles: roles,
+ fields: fields
+ }.to_json
+ }
+ ]
+ }
+ end
+ end
+ end
+end
diff --git a/lib/mcp/tools/send_documents.rb b/lib/mcp/tools/send_documents.rb
index 25b1be72..461728e6 100644
--- a/lib/mcp/tools/send_documents.rb
+++ b/lib/mcp/tools/send_documents.rb
@@ -31,6 +31,27 @@ module Mcp
phone: {
type: 'string',
description: 'Submitter phone number in E.164 format'
+ },
+ role: {
+ type: 'string',
+ description: 'Signing role name from the template'
+ },
+ fields: {
+ type: 'array',
+ description: 'Prefill field values for this submitter (fields become readonly)',
+ items: {
+ type: 'object',
+ properties: {
+ name: {
+ type: 'string',
+ description: 'Field name'
+ },
+ value: {
+ description: 'Prefilled value for the field'
+ }
+ },
+ required: %w[name value]
+ }
}
}
}
@@ -59,9 +80,17 @@ module Mcp
return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
submitters = (arguments['submitters'] || []).map do |s|
- s.slice('email', 'name', 'role', 'phone')
- .compact_blank
- .with_indifferent_access
+ attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
+
+ fields = Array.wrap(s['fields']).filter_map do |f|
+ next if f['name'].blank?
+
+ { 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
+ end
+
+ attrs['fields'] = fields if fields.present?
+
+ attrs.with_indifferent_access
end
submissions = Submissions.create_from_submitters(
diff --git a/lib/pdfium.rb b/lib/pdfium.rb
index 7b4ba1fe..158335ce 100644
--- a/lib/pdfium.rb
+++ b/lib/pdfium.rb
@@ -39,6 +39,16 @@ class Pdfium
FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800
+ TextObject = Struct.new(:content, :x, :y, :w, :h, :font_size) do
+ def endx
+ @endx ||= x + w
+ end
+
+ def endy
+ @endy ||= y + h
+ end
+ end
+
TextNode = Struct.new(:content, :x, :y, :w, :h) do
def endx
@endx ||= x + w
@@ -117,6 +127,10 @@ class Pdfium
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
+ # Text page object functions (per-run Tj/TJ extraction)
+ attach_function :FPDFTextObj_GetText, %i[FPDF_PAGEOBJECT FPDF_TEXTPAGE pointer ulong], :ulong
+ attach_function :FPDFTextObj_GetFontSize, %i[FPDF_PAGEOBJECT pointer], :int
+
# Page object types
FPDF_PAGEOBJ_UNKNOWN = 0
FPDF_PAGEOBJ_TEXT = 1
@@ -515,6 +529,90 @@ class Pdfium
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end
+ def text_objects
+ return @text_objects if @text_objects
+
+ ensure_not_closed!
+
+ @text_objects = []
+
+ object_count = Pdfium.FPDFPage_CountObjects(page_ptr)
+
+ return @text_objects if object_count.zero?
+
+ text_page = Pdfium.FPDFText_LoadPage(page_ptr)
+
+ if text_page.null?
+ Pdfium.check_last_error("Failed to load text page #{page_index}")
+
+ raise PdfiumError, "Failed to load text page #{page_index}, pointer is NULL."
+ end
+
+ left_ptr = FFI::MemoryPointer.new(:float)
+ bottom_ptr = FFI::MemoryPointer.new(:float)
+ right_ptr = FFI::MemoryPointer.new(:float)
+ top_ptr = FFI::MemoryPointer.new(:float)
+ font_size_ptr = FFI::MemoryPointer.new(:float)
+
+ object_count.times do |i|
+ page_object = Pdfium.FPDFPage_GetObject(page_ptr, i)
+
+ next if page_object.null?
+
+ next unless Pdfium.FPDFPageObj_GetType(page_object) == Pdfium::FPDF_PAGEOBJ_TEXT
+
+ needed_bytes = Pdfium.FPDFTextObj_GetText(page_object, text_page, FFI::Pointer::NULL, 0)
+
+ next if needed_bytes < 4
+
+ buffer = FFI::MemoryPointer.new(:uint8, needed_bytes)
+
+ written = Pdfium.FPDFTextObj_GetText(page_object, text_page, buffer, needed_bytes)
+
+ next if written < 4
+
+ content = buffer.read_bytes(written - 2).force_encoding('UTF-16LE').encode('UTF-8')
+
+ next if content.empty?
+
+ next if Pdfium.FPDFPageObj_GetBounds(page_object, left_ptr, bottom_ptr, right_ptr, top_ptr).zero?
+
+ obj_left = left_ptr.read_float
+ obj_bottom = bottom_ptr.read_float
+ obj_right = right_ptr.read_float
+ obj_top = top_ptr.read_float
+
+ obj_width = obj_right - obj_left
+ obj_height = obj_top - obj_bottom
+
+ next if obj_width <= 0 || obj_height <= 0
+
+ font_size =
+ if Pdfium.FPDFTextObj_GetFontSize(page_object, font_size_ptr) == 0
+ obj_height
+ else
+ font_size_ptr.read_float
+ end
+
+ font_size = 8 if font_size == 1
+
+ norm_x = obj_left / width
+ norm_y = (height - obj_top) / height
+ norm_w = obj_width / width
+ norm_h = obj_height / height
+
+ @text_objects << TextObject.new(content, norm_x, norm_y, norm_w, norm_h, font_size)
+ end
+
+ y_threshold = 4.0 / width
+
+ @text_objects = @text_objects.sort do |a, b|
+ (a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy
+ end
+ ensure
+ Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
+ end
+
def line_nodes
return @line_nodes if @line_nodes
diff --git a/lib/submissions/create_from_submitters.rb b/lib/submissions/create_from_submitters.rb
index aabb2e49..e04167ad 100644
--- a/lib/submissions/create_from_submitters.rb
+++ b/lib/submissions/create_from_submitters.rb
@@ -56,6 +56,8 @@ module Submissions
template_submitter = template_submitters.find { |e| e['uuid'] == uuid }
end
+ raise BaseError, 'Invalid submitter params' unless template_submitter
+
template_submitter = template_submitter.except('optional_invite_by_uuid', 'invite_by_uuid',
'invite_via_field_uuid')
diff --git a/lib/submissions/generate_export_files.rb b/lib/submissions/generate_export_files.rb
index 23bc843d..f1096db2 100644
--- a/lib/submissions/generate_export_files.rb
+++ b/lib/submissions/generate_export_files.rb
@@ -150,6 +150,8 @@ module Submissions
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment
end
+ elsif submitter_value == true || submitter_value == false
+ submitter_value.to_s
else
submitter_value
end
diff --git a/lib/submitters/submit_values.rb b/lib/submitters/submit_values.rb
index 1062c5a9..8b5f7187 100644
--- a/lib/submitters/submit_values.rb
+++ b/lib/submitters/submit_values.rb
@@ -107,24 +107,33 @@ module Submitters
reason_field_uuid = params[:with_reason]
signature_field_uuid = values.except(reason_field_uuid).keys.first
- signature_field = submitter.submission.template_fields.find { |e| e['uuid'] == signature_field_uuid }
-
- signature_field['preferences'] ||= {}
- signature_field['preferences']['reason_field_uuid'] = reason_field_uuid
+ signature_field = submitter.submission.template_fields.find do |e|
+ e['uuid'] == signature_field_uuid && e['submitter_uuid'] == submitter.uuid
+ end
- reason_field = submitter.submission.template_fields.find { |e| e['uuid'] == reason_field_uuid }
+ reason_field = submitter.submission.template_fields.find do |e|
+ e['uuid'] == reason_field_uuid && e['submitter_uuid'] == submitter.uuid
+ end
- unless reason_field
+ if reason_field
+ if reason_field.dig('preferences', 'signature_field_uuid') != signature_field['uuid']
+ raise ValidationError, 'Invalid field'
+ end
+ else
reason_field = { 'type' => 'text',
'uuid' => reason_field_uuid,
'name' => I18n.t(:reason),
'readonly' => true,
+ 'preferences' => { 'signature_field_uuid' => signature_field['uuid'] },
'submitter_uuid' => submitter.uuid }
submitter.submission.template_fields.insert(submitter.submission.template_fields.index(signature_field) + 1,
reason_field)
end
+ signature_field['preferences'] ||= {}
+ signature_field['preferences']['reason_field_uuid'] = reason_field_uuid
+
submitter.submission.save!
reason_field
@@ -454,6 +463,9 @@ module Submitters
end
def validate_value!(_value, field, _params, submitter, _request)
+ raise ValidationError, 'Missing field' unless field
+ raise ValidationError, 'Invalid field' if field['submitter_uuid'] != submitter.uuid
+
if field['readonly'] == true
Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar)
diff --git a/lib/templates/detect_fields.rb b/lib/templates/detect_fields.rb
index a0c81334..4d2fa0a7 100755
--- a/lib/templates/detect_fields.rb
+++ b/lib/templates/detect_fields.rb
@@ -114,36 +114,44 @@ module Templates
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
tail_node = head_node
- page_range = page_number ? [page_number] : (0...doc.page_count)
+ page_indexes = page_number ? [page_number] : (0...doc.page_count).to_a
- fields = page_range.flat_map do |current_page_number|
- next [] if current_page_number >= doc.page_count
+ prep_opts = { aspect_ratio:, padding:, split_page: }
+ infer_opts = { confidence: confidence / 3.0, nms:, nmm:, temperature: }
- page = doc.get_page(current_page_number)
+ 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)
- size_key = page.width > page.height ? :width : :height
- size = padding ? inference.resolution * 1.5 : inference.resolution
+ all_fields = []
- data, width, height = page.render_to_bitmap(size_key => size)
+ page_indexes.each_with_index do |current_page_number, i|
+ next_n = page_indexes[i + 1]
- image = Vips::Image.new_from_memory(data, width, height, 4, :uchar)
+ 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
- fields = inference.call(image, confidence: confidence / 3.0, nms:, nmm:, split_page:,
- temperature:, aspect_ratio:, padding:)
+ outputs = current_wait.call
- text_fields = extract_text_fields_from_page(page)
- line_fields = extract_line_fields_from_page(page)
+ next_wait = next_args ? inference.enqueue(**next_args, **infer_opts) : nil
- fields = sort_fields(fields, y_threshold: 10.0 / image.height)
+ 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)
fields = increase_confidence_for_overlapping_fields(fields, text_fields, confidence:)
fields = increase_confidence_for_overlapping_fields(fields, line_fields, confidence:)
fields = fields.reject { |f| f.confidence < confidence }
- field_nodes, tail_node = build_page_nodes(page, fields, tail_node, attachment_uuid: attachment&.uuid)
+ field_nodes, tail_node = build_page_nodes(current_page, fields, tail_node, attachment_uuid: attachment&.uuid)
- fields = field_nodes.map do |node|
+ page_fields = field_nodes.map do |node|
field = node.elem
type = regexp_type ? type_from_page_node(node) : field.type
@@ -162,20 +170,32 @@ module Templates
}
end
- yield [attachment&.uuid, current_page_number, fields] if block_given?
+ yield [attachment&.uuid, current_page_number, page_fields] if block_given?
- fields
+ all_fields.concat(page_fields)
+
+ current_args = next_args
+ current_wait = next_wait
ensure
- page.close
+ current_page&.close
end
print_debug(head_node) if Rails.env.development?
- [fields, head_node]
+ [all_fields, head_node]
ensure
doc.close
end
+ 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
+
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
diff --git a/lib/templates/image_to_fields.rb b/lib/templates/image_to_fields.rb
index c25bd2fe..3dcd97d3 100755
--- a/lib/templates/image_to_fields.rb
+++ b/lib/templates/image_to_fields.rb
@@ -73,6 +73,18 @@ module Templates
build_fields_from_detections(detections, image)
end
+ def prepare_input(image, **opts)
+ { image:, **opts }
+ end
+
+ def enqueue(image:, **infer_opts)
+ -> { call(image, **infer_opts) }
+ end
+
+ def process_outputs(outputs, **)
+ outputs
+ end
+
def call_v2(image, offset_x, offset_y, split_page, confidence:, resolution:)
if split_page && image.height > image.width
regions = build_split_image_regions(image)
diff --git a/spec/system/signing_form_spec.rb b/spec/system/signing_form_spec.rb
index c5ebb4dc..88884d6d 100644
--- a/spec/system/signing_form_spec.rb
+++ b/spec/system/signing_form_spec.rb
@@ -613,7 +613,7 @@ RSpec.describe 'Signing Form' do
visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click
- click_link 'Type'
+ click_button 'Type'
fill_in 'signature_text_input', with: 'John Doe'
click_button 'Sign and Complete'
@@ -752,7 +752,7 @@ RSpec.describe 'Signing Form' do
visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click
- click_link 'Draw'
+ click_button 'Draw'
draw_canvas
click_button 'Complete'
@@ -1169,7 +1169,7 @@ RSpec.describe 'Signing Form' do
find('#decline_button').click
fill_in 'reason', with: 'I do not agree with the terms'
- click_button 'Decline'
+ within('dialog[open]') { click_button 'Decline' }
expect(page).to have_content('Form has been declined')
@@ -1193,7 +1193,7 @@ RSpec.describe 'Signing Form' do
find('#delegate_button').click
fill_in 'email', with: 'delegate@example.com'
- click_button 'Delegate'
+ within('dialog[open]') { click_button 'Delegate' }
expect(page).to have_content('Document has been delegated')