From 0838d923f0084018e8d39f8e116cfd352b87b0e6 Mon Sep 17 00:00:00 2001 From: Paul Sandhu Date: Mon, 11 May 2026 15:34:18 -0700 Subject: [PATCH 1/8] CP-13553 Generate all PDF page previews at upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocuSeal previously rendered only the first 15 pages of a PDF synchronously at upload time and then lazily generated pages 16+ on demand when the editor or submitter view actually requested them. The on-demand path lived in PreviewDocumentPageController and had a long history of bugs — tempfile races, intermittent failures, filename-extension mismatches between writer and reader — especially when DocuSeal is iframed into ATS. This removes the 15-page cap (MAX_NUMBER_OF_PAGES_PROCESSED) and the lazy on-demand controller entirely. Every page now renders during the upload request. Larger PDFs take a few extra seconds at upload, but every page is guaranteed to exist by the time the editor opens, so the whole on-demand failure surface disappears. Also deletes two unused job classes (GeneratePreviewImagesJob and GenerateAttachmentPreviewJob) that had no callers anywhere in the app, plus the now-obsolete spec for the deleted generate_pdf_preview_from_file helper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../preview_document_page_controller.rb | 52 ------------------- app/jobs/generate_attachment_preview_job.rb | 21 -------- app/jobs/generate_preview_images_job.rb | 17 ------ config/routes.rb | 1 - lib/templates/process_document.rb | 27 ++-------- spec/lib/templates/process_document_spec.rb | 27 ---------- 6 files changed, 4 insertions(+), 141 deletions(-) delete mode 100644 app/controllers/preview_document_page_controller.rb delete mode 100644 app/jobs/generate_attachment_preview_job.rb delete mode 100644 app/jobs/generate_preview_images_job.rb delete mode 100644 spec/lib/templates/process_document_spec.rb diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb deleted file mode 100644 index 5e59e74a2..000000000 --- a/app/controllers/preview_document_page_controller.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -class PreviewDocumentPageController < ActionController::API - include ActiveStorage::SetCurrent - - FORMAT = Templates::ProcessDocument::FORMAT - - def show - attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment) - - attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid - - return head :not_found unless attachment - - @template = attachment.record - - preview_image = attachment.preview_images.joins(:blob) - .find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] }) - - return redirect_to preview_image.url, allow_other_host: true if preview_image - - file_path = - if attachment.service.name == :disk - ActiveStorage::Blob.service.path_for(attachment.key) - else - find_or_create_document_tempfile_path(attachment) - end - - preview_image = - Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i) - - redirect_to preview_image.url, allow_other_host: true - end - - def find_or_create_document_tempfile_path(attachment) - file_path = "#{Dir.tmpdir}/#{attachment.uuid}" - - File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f| - f.flock(File::LOCK_EX) - - # rubocop:disable Style/ZeroLengthPredicate - if f.size.zero? - f.binmode - - f.write(attachment.download) - end - # rubocop:enable Style/ZeroLengthPredicate - end - - file_path - end -end diff --git a/app/jobs/generate_attachment_preview_job.rb b/app/jobs/generate_attachment_preview_job.rb deleted file mode 100644 index 6d0327118..000000000 --- a/app/jobs/generate_attachment_preview_job.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class GenerateAttachmentPreviewJob - include Sidekiq::Job - - InvalidFormat = Class.new(StandardError) - - sidekiq_options queue: :images - - def perform(params = {}) - attachment = ActiveStorage::Attachment.find(params['attachment_id']) - - if attachment.content_type == Templates::ProcessDocument::PDF_CONTENT_TYPE - Templates::ProcessDocument.generate_pdf_preview_images(attachment, attachment.download) - elsif attachment.image? - Templates::ProcessDocument.generate_preview_image(attachment, attachment.download) - else - raise InvalidFormat, attachment.id - end - end -end diff --git a/app/jobs/generate_preview_images_job.rb b/app/jobs/generate_preview_images_job.rb deleted file mode 100644 index ef52c0786..000000000 --- a/app/jobs/generate_preview_images_job.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class GeneratePreviewImagesJob - include Sidekiq::Job - - sidekiq_options queue: :images - - def perform(params = {}) - attachment = ActiveStorage::Attachment.find(params['attachment_id']) - - max_page = [attachment.metadata['pdf']['number_of_pages'].to_i - 1, - Templates::ProcessDocument::MAX_NUMBER_OF_PAGES_PROCESSED].min - - Templates::ProcessDocument.generate_document_preview_images(attachment, attachment.download, (1..max_page), - concurrency: 1) - end -end diff --git a/config/routes.rb b/config/routes.rb index 2ad783d7c..8f8099acf 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -140,7 +140,6 @@ resources :recipients, only: %i[create], controller: 'templates_recipients' resources :submissions_export, only: %i[index new] end - resources :preview_document_page, only: %i[show], path: '/preview/:signed_uuid' resource :blobs_proxy, only: %i[show], path: '/file/:signed_uuid/*filename', controller: 'api/active_storage_blobs_proxy' resource :blobs_proxy, only: %i[show], path: '/blobs_proxy/:signed_uuid/*filename', diff --git a/lib/templates/process_document.rb b/lib/templates/process_document.rb index 26ccd9a9d..92988e991 100644 --- a/lib/templates/process_document.rb +++ b/lib/templates/process_document.rb @@ -12,13 +12,11 @@ module ProcessDocument Q = 95 JPEG_Q = ENV.fetch('PAGE_QUALITY', '35').to_i MAX_WIDTH = 1400 - MAX_NUMBER_OF_PAGES_PROCESSED = 15 MAX_FLATTEN_FILE_SIZE = 20.megabytes - GENERATE_PREVIEW_SIZE_LIMIT = 50.megabytes module_function - def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) + def call(attachment, data, extract_fields: false) if attachment.content_type == PDF_CONTENT_TYPE if extract_fields && data.size < MAX_FLATTEN_FILE_SIZE pdf = HexaPDF::Document.new(io: StringIO.new(data)) @@ -26,7 +24,7 @@ def call(attachment, data, extract_fields: false, max_pages: MAX_NUMBER_OF_PAGES fields = Templates::FindAcroFields.call(pdf, attachment, data) end - generate_pdf_preview_images(attachment, data, pdf, max_pages:) + generate_pdf_preview_images(attachment, data, pdf) attachment.metadata['pdf']['fields'] = fields if fields elsif attachment.image? @@ -81,7 +79,7 @@ def generate_preview_image(attachment, data) ) end - def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMBER_OF_PAGES_PROCESSED) + def generate_pdf_preview_images(attachment, data, pdf = nil) ActiveStorage::Attachment.where(name: ATTACHMENT_NAME, record: attachment).destroy_all pdf ||= HexaPDF::Document.new(io: StringIO.new(data)) @@ -96,9 +94,7 @@ def generate_pdf_preview_images(attachment, data, pdf = nil, max_pages: MAX_NUMB attachment.save! end - max_pages_to_process = data.size < GENERATE_PREVIEW_SIZE_LIMIT ? max_pages : 1 - - generate_document_preview_images(attachment, data, (0..[number_of_pages - 1, max_pages_to_process].min)) + generate_document_preview_images(attachment, data, (0..number_of_pages - 1)) end def generate_document_preview_images(attachment, data, range, concurrency: CONCURRENCY) @@ -201,20 +197,5 @@ def normalize_attachment_fields(template, attachments = template.documents) end end - def generate_pdf_preview_from_file(attachment, file_path, page_number) - doc = Pdfium::Document.open_file(file_path) - - blob = build_and_upload_blob(doc, page_number, '.jpg') - - ApplicationRecord.no_touching do - ActiveStorage::Attachment.create!( - blob: blob, - name: ATTACHMENT_NAME, - record: attachment - ) - end - ensure - doc&.close - end end end diff --git a/spec/lib/templates/process_document_spec.rb b/spec/lib/templates/process_document_spec.rb deleted file mode 100644 index cd7bb4996..000000000 --- a/spec/lib/templates/process_document_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -describe Templates::ProcessDocument do - describe '.generate_pdf_preview_from_file' do - let(:account) { create(:account) } - let(:user) { create(:user, account:) } - let(:template) { create(:template, account:, author: user, attachment_count: 0) } - let(:attachment) do - blob = ActiveStorage::Blob.create_and_upload!( - io: Rails.root.join('spec/fixtures/sample-document.pdf').open, - filename: 'sample-document.pdf', - content_type: 'application/pdf' - ) - ActiveStorage::Attachment.create!(blob: blob, name: :documents, record: template) - end - let(:file_path) { ActiveStorage::Blob.service.path_for(attachment.key) } - - it 'saves the preview blob under a filename the PreviewDocumentPageController cache lookup matches' do - described_class.generate_pdf_preview_from_file(attachment, file_path, 0) - - cached = attachment.preview_images.joins(:blob) - .find_by(blob: { filename: ['0.png', '0.jpg'] }) - - expect(cached).not_to be_nil - end - end -end From 2e7b71223ed4511c25706a43f1da6dac27191f23 Mon Sep 17 00:00:00 2001 From: Paul Sandhu Date: Mon, 11 May 2026 15:34:30 -0700 Subject: [PATCH 2/8] CP-13553 Drop lazy preview fallback from the Vue editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The template builder used to fall back to a /preview//.jpg URL for any page that wasn't already in document.preview_images, and applied loading="lazy" to each page . Both pieces only existed to paper over the missing pages 16+ from the old upload cap. Since the backend now renders every page at upload time, the fallback URL is never needed. sortedPreviewImages becomes a simple map-filter over the already-rendered images, and basePreviewUrl is unused so it's removed too. Also drops loading="lazy" from page.vue and preview.vue. Native lazy loading does not trigger reliably inside the ATS iframe — images would sometimes refuse to fetch until the user scroll-spammed past them. With every page now reachable as a direct S3 URL on initial render, the browser can just load them up front. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/javascript/template_builder/document.vue | 19 +++---------------- app/javascript/template_builder/page.vue | 1 - app/javascript/template_builder/preview.vue | 1 - 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index 2c7592c75..1893a1e45 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -113,26 +113,13 @@ export default { } }, computed: { - basePreviewUrl () { - if (this.baseUrl) { - return new URL(this.baseUrl).origin - } else { - return '' - } - }, numberOfPages () { return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length }, sortedPreviewImages () { - const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata - - return [...Array(this.numberOfPages).keys()].map((i) => { - return this.previewImagesIndex[i] || { - metadata: lazyloadMetadata, - id: Math.random().toString(), - url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg` - } - }) + return [...Array(this.numberOfPages).keys()] + .map((i) => this.previewImagesIndex[i]) + .filter(Boolean) }, previewImagesIndex () { return this.document.preview_images.reduce((acc, e) => { diff --git a/app/javascript/template_builder/page.vue b/app/javascript/template_builder/page.vue index 29e2ed4e2..3044f4212 100644 --- a/app/javascript/template_builder/page.vue +++ b/app/javascript/template_builder/page.vue @@ -6,7 +6,6 @@ >
Date: Mon, 11 May 2026 15:34:41 -0700 Subject: [PATCH 3/8] CP-13553 Drop lazy preview fallback from submission views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the previous commit but for the Rails-rendered submit-form and completed-submission views. Drops the page_blob_struct / lazyload_metadata machinery that built fallback URLs pointing at the now-deleted /preview controller, and removes loading="lazy" from the page-list tags for the same iframe-reliability reasons as the editor change. Single-image attachment thumbnails (signatures, user-uploaded files) keep their loading="lazy" attributes — those aren't part of a scrollable page list and don't hit the same iframe-rendering quirk. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/views/submissions/show.html.erb | 7 +++---- app/views/submit_form/show.html.erb | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/views/submissions/show.html.erb b/app/views/submissions/show.html.erb index 2c805a3f2..2509e820a 100644 --- a/app/views/submissions/show.html.erb +++ b/app/views/submissions/show.html.erb @@ -38,16 +38,15 @@ <% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %> <% submitters_index = @submission.submitters.index_by(&:uuid) %> <% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %> - <% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %> <% schema.each do |item| %> <% document = @submission.schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %> <% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> - <% lazyload_metadata = document.preview_images.first.metadata %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> - <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %> + <% page = preview_images_index[index] %> + <% next unless page %>
" class="relative"> - +
<% document_annots_index[index]&.each do |annot| %> <%= render 'submissions/annotation', annot: %> diff --git a/app/views/submit_form/show.html.erb b/app/views/submit_form/show.html.erb index 22cc19f18..f577b55e9 100644 --- a/app/views/submit_form/show.html.erb +++ b/app/views/submit_form/show.html.erb @@ -6,7 +6,6 @@ <% values = merge_prefill_values(submitter_values, @prefill_values || {}, template_fields) %> <% submitters_index = @submitter.submission.submitters.index_by(&:uuid) %> -<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %> <% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
@@ -20,11 +19,11 @@
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> - <% lazyload_metadata = document.preview_images.last&.metadata || {} %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> - <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_uuid, "#{index}.jpg")) %> + <% page = preview_images_index[index] %> + <% next unless page %>
- +
<% if annots = document_annots_index[index] %> <%= render 'submit_form/annotations', annots: %> From 171c02e2120f60308cbdb0ec8fb878ad403356d3 Mon Sep 17 00:00:00 2001 From: Paul Sandhu Date: Mon, 11 May 2026 15:48:51 -0700 Subject: [PATCH 4/8] CP-13553 Always preload preview images and drop dead blob preloads The old Submissions.preload_with_pages had two pieces that no longer make sense after removing the lazy preview controller: 1. It preloaded `:blob` on the document attachments themselves. The views only ever accessed `document.blob` indirectly through the now-deleted lazy controller's `attachment.download` call. Bullet was correctly flagging the preload as unused. 2. It skipped preloading preview_images for documents with 200+ pages and relied on the views' lazy-URL fallback to fill the gap. With the fallback gone, large documents were rendering zero pages in the Rails form preview because preview_images.loaded? returned false and the index ended up empty. This always preloads `preview_images_attachments: :blob` regardless of page count, and drops both unused `:blob` preloads on the document attachments. Also removes the now-unused PRELOAD_ALL_PAGES_AMOUNT constants in lib/submissions.rb and lib/submitters.rb. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/submissions.rb | 17 +++++------------ lib/submitters.rb | 1 - 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/submissions.rb b/lib/submissions.rb index 1ffba7956..c94aa280e 100644 --- a/lib/submissions.rb +++ b/lib/submissions.rb @@ -3,8 +3,6 @@ module Submissions DEFAULT_SUBMITTERS_ORDER = 'single_sided' - PRELOAD_ALL_PAGES_AMOUNT = 200 - module_function def search(current_user, submissions, keyword, search_values: false, search_template: false) @@ -79,19 +77,14 @@ def preload_with_pages(submission) ActiveRecord::Associations::Preloader.new( records: [submission], associations: [ - submission.template_id? ? { template_schema_documents: :blob } : { documents_attachments: :blob } + submission.template_id? ? :template_schema_documents : :documents_attachments ] ).call - total_pages = - submission.schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i } - - if total_pages < PRELOAD_ALL_PAGES_AMOUNT - ActiveRecord::Associations::Preloader.new( - records: submission.schema_documents, - associations: [:blob, { preview_images_attachments: :blob }] - ).call - end + ActiveRecord::Associations::Preloader.new( + records: submission.schema_documents, + associations: [{ preview_images_attachments: :blob }] + ).call submission end diff --git a/lib/submitters.rb b/lib/submitters.rb index d31386a78..9c4dafc88 100644 --- a/lib/submitters.rb +++ b/lib/submitters.rb @@ -2,7 +2,6 @@ module Submitters TRUE_VALUES = ['1', 'true', true].freeze - PRELOAD_ALL_PAGES_AMOUNT = 200 FIELD_NAME_WEIGHTS = { 'email' => 'A', From c3dc6683b09e6724c04488c2362c6427921f8ba0 Mon Sep 17 00:00:00 2001 From: Paul Sandhu Date: Mon, 11 May 2026 16:06:27 -0700 Subject: [PATCH 5/8] Update copy to be more user friendly Does what it says on the box. --- app/javascript/template_builder/dropzone.vue | 8 +++++++- app/javascript/template_builder/i18n.js | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/javascript/template_builder/dropzone.vue b/app/javascript/template_builder/dropzone.vue index 2485bd23a..453491fb8 100644 --- a/app/javascript/template_builder/dropzone.vue +++ b/app/javascript/template_builder/dropzone.vue @@ -35,11 +35,17 @@ {{ message }}
{{ t('click_to_upload') }} {{ t('or_drag_and_drop_files') }}
+
+ {{ t('larger_pdfs_may_take_a_while_to_process') }} +
Date: Tue, 12 May 2026 09:22:53 -0700 Subject: [PATCH 6/8] Update app/javascript/template_builder/document.vue Co-authored-by: Bernardo Anderson --- app/javascript/template_builder/document.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index 1893a1e45..a7c478330 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -118,7 +118,7 @@ export default { }, sortedPreviewImages () { return [...Array(this.numberOfPages).keys()] - .map((i) => this.previewImagesIndex[i]) + .map((i) => this.previewImagesIndex[i] ? { ...this.previewImagesIndex[i], _pageIndex: i } : null) .filter(Boolean) }, previewImagesIndex () { From 7b50448e07a4f15224d79a9e197d30eb79948511 Mon Sep 17 00:00:00 2001 From: Paul Sandhu Date: Tue, 12 May 2026 12:18:21 -0700 Subject: [PATCH 7/8] PR feedback --- app/javascript/template_builder/document.vue | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/javascript/template_builder/document.vue b/app/javascript/template_builder/document.vue index a7c478330..099c95122 100644 --- a/app/javascript/template_builder/document.vue +++ b/app/javascript/template_builder/document.vue @@ -95,11 +95,6 @@ export default { required: false, default: null }, - baseUrl: { - type: String, - required: false, - default: '' - }, isDrag: { type: Boolean, required: false, From eb6c3bb73c3c1f7e1dfa2844031a045c7a1d3590 Mon Sep 17 00:00:00 2001 From: Paul Sandhu Date: Tue, 12 May 2026 12:31:36 -0700 Subject: [PATCH 8/8] RIP BaseURL --- app/javascript/template_builder/builder.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index f576ffc84..17e6a248b 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -203,7 +203,6 @@ :draw-field="drawField" :draw-field-type="drawFieldType" :editable="editable" - :base-url="baseUrl" @draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]" @drop-field="onDropfield" @remove-area="removeArea"