From d14e7f6b7ef52155ba3d7a38b60563a0da82e1ce Mon Sep 17 00:00:00 2001 From: Bernardo Anderson Date: Mon, 3 Aug 2026 20:16:46 -0500 Subject: [PATCH 1/2] CP-14715 - Fix auto-detected form fields on rotated PDFs What When a PDF page is stored with a rotation flag, auto-detected fillable fields now line up with the on-page widgets in the template editor, and filled values land in the correct boxes when the document is completed. Upright pages are unchanged. Why Auto-detect placed fields in the PDF's unrotated coordinate space while the editor and signing output use the rotated visual layout, so fields and values appeared offset on rotated pages. How to test 1. Create a DocuSeal paperwork task in ATS and open the template editor iframe. 2. Upload a fillable PDF with a rotated page (e.g. docuseal/tmp/paper_audit/fixtures/acro-rot90.pdf). 3. Confirm auto-detected field boxes overlay the on-page widgets (not floating elsewhere). 4. Optionally complete a signing and confirm values sit inside the boxes. 5. Upload an upright fillable PDF (e.g. acro-letter.pdf) and confirm fields still align. 6. Or run: bundle exec rspec spec/lib/templates/find_acro_fields_spec.rb --- lib/templates/find_acro_fields.rb | 22 +++++ spec/lib/templates/find_acro_fields_spec.rb | 103 ++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 spec/lib/templates/find_acro_fields_spec.rb diff --git a/lib/templates/find_acro_fields.rb b/lib/templates/find_acro_fields.rb index af0ee395d9..935bf539e6 100644 --- a/lib/templates/find_acro_fields.rb +++ b/lib/templates/find_acro_fields.rb @@ -29,6 +29,9 @@ module FindAcroFields 2 => 'right' }.freeze + # /Rotate values that swap media width and height in visual space. + SWAP_DIMENSION_ROTATIONS = [90, 270].freeze + module_function # rubocop:disable Metrics @@ -59,6 +62,14 @@ def call(pdf, attachment, data) page_width = media_box[2] - media_box[0] page_height = media_box[3] - media_box[1] + # Transform media-space Rect into visual (post-rotation) space so + # areas match Pdfium previews and fill-time page.rotate(flatten: true). + rotation = (page[:Rotate] || 0).to_i % 360 + if rotation != 0 + x0, y0, x1, y1 = apply_page_rotation(x0, y0, x1, y1, rotation, page_width, page_height) + page_width, page_height = page_height, page_width if SWAP_DIMENSION_ROTATIONS.include?(rotation) + end + x = x0 y = y0 w = x1 - x0 @@ -124,6 +135,17 @@ def correct_coordinates(x_coord, y_coord, shift, media_box_start) [corrected_x, corrected_y] end + # Map a media-space Rect through PDF /Rotate (clockwise degrees) into visual + # page space. Mirrors HexaPDF::Type::Page#rotate(0, flatten: true) corner math. + def apply_page_rotation(llx, lly, urx, ury, rotation, width, height) + case rotation + when 90 then [lly, width - urx, ury, width - llx] + when 180 then [width - urx, height - ury, width - llx, height - lly] + when 270 then [height - ury, llx, height - lly, urx] + else [llx, lly, urx, ury] + end + end + def build_field_properties(field) field_name = field.full_field_name if field.full_field_name.to_s.match?(FIELD_NAME_REGEXP) diff --git a/spec/lib/templates/find_acro_fields_spec.rb b/spec/lib/templates/find_acro_fields_spec.rb new file mode 100644 index 0000000000..754b15e0b1 --- /dev/null +++ b/spec/lib/templates/find_acro_fields_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Templates::FindAcroFields do + # Letter media box; field Rect chosen so expected fractions are simple. + let(:page_w) { 612.0 } + let(:page_h) { 792.0 } + let(:field_rect) { [100.0, 100.0, 300.0, 130.0] } # x0, y0, x1, y1 (PDF bottom-left origin) + let(:attachment) { instance_double(ActiveStorage::Attachment, uuid: SecureRandom.uuid) } + + def build_pdf(rotate: nil) + doc = HexaPDF::Document.new + page = doc.pages.add([0, 0, page_w, page_h]) + page[:Rotate] = rotate if rotate && rotate != 0 + + form = doc.acro_form(create: true) + form.create_text_field('name').create_widget(page, Rect: field_rect.dup) + + doc + end + + def extract_area(rotate: nil) + pdf = build_pdf(rotate:) + fields = described_class.call(pdf, attachment, '') + expect(fields).not_to be_empty + fields.first[:areas].first + end + + def expect_area(area, expected_x:, expected_y:, expected_w:, expected_h:) + expect(area[:x]).to be_within(1e-6).of(expected_x) + expect(area[:y]).to be_within(1e-6).of(expected_y) + expect(area[:w]).to be_within(1e-6).of(expected_w) + expect(area[:h]).to be_within(1e-6).of(expected_h) + expect(area[:page]).to eq(0) + expect(area[:attachment_uuid]).to eq(attachment.uuid) + end + + describe '.call with page rotation' do + it 'preserves media-normalized coords when /Rotate is absent' do + area = extract_area(rotate: nil) + + expect_area( + area, + expected_x: 100 / page_w, + expected_y: (page_h - 130) / page_h, + expected_w: 200 / page_w, + expected_h: 30 / page_h + ) + end + + it 'preserves media-normalized coords when /Rotate is 0' do + area = extract_area(rotate: 0) + + expect_area( + area, + expected_x: 100 / page_w, + expected_y: (page_h - 130) / page_h, + expected_w: 200 / page_w, + expected_h: 30 / page_h + ) + end + + it 'maps fields into visual space for /Rotate 90' do + # Visual rect: [100, 312, 130, 512], visual dims 792×612 + area = extract_area(rotate: 90) + + expect_area( + area, + expected_x: 100 / page_h, + expected_y: (page_w - 512) / page_w, + expected_w: 30 / page_h, + expected_h: 200 / page_w + ) + end + + it 'maps fields into visual space for /Rotate 180' do + # Visual rect: [312, 662, 512, 692], visual dims 612×792 + area = extract_area(rotate: 180) + + expect_area( + area, + expected_x: 312 / page_w, + expected_y: (page_h - 692) / page_h, + expected_w: 200 / page_w, + expected_h: 30 / page_h + ) + end + + it 'maps fields into visual space for /Rotate 270' do + # Visual rect: [662, 100, 692, 300], visual dims 792×612 + area = extract_area(rotate: 270) + + expect_area( + area, + expected_x: 662 / page_h, + expected_y: (page_w - 300) / page_w, + expected_w: 30 / page_h, + expected_h: 200 / page_w + ) + end + end +end From 0b11d3560266eadc2f9b5657fbf53e0145c62d16 Mon Sep 17 00:00:00 2001 From: Bernardo Anderson Date: Tue, 4 Aug 2026 11:08:59 -0500 Subject: [PATCH 2/2] CP-14715 - PR review recommendations --- lib/templates/find_acro_fields.rb | 6 +++ spec/lib/templates/find_acro_fields_spec.rb | 45 ++++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/lib/templates/find_acro_fields.rb b/lib/templates/find_acro_fields.rb index 935bf539e6..32b1f861cb 100644 --- a/lib/templates/find_acro_fields.rb +++ b/lib/templates/find_acro_fields.rb @@ -88,6 +88,9 @@ def call(pdf, attachment, data) next if attrs[:w].zero? || attrs[:h].zero? + # Comb cell_w still divides visual width by MaxLen. On 90/270 pages a comb + # whose cells ran along media-x can get the wrong axis; pre-existing, not + # fixed by the rotation map above (would need axis-aware cell spacing). if child_field[:MaxLen] && child_field.try(:concrete_field_type) == :comb_text_field attrs[:cell_w] = w / page_width.to_f / child_field[:MaxLen].to_f end @@ -137,6 +140,9 @@ def correct_coordinates(x_coord, y_coord, shift, media_box_start) # Map a media-space Rect through PDF /Rotate (clockwise degrees) into visual # page space. Mirrors HexaPDF::Type::Page#rotate(0, flatten: true) corner math. + # HexaPDF builds its flatten matrix from absolute box edges; this takes width/ + # height extents and only matches that math because correct_coordinates has + # already shifted the Rect onto a 0-origin box before we get here. def apply_page_rotation(llx, lly, urx, ury, rotation, width, height) case rotation when 90 then [lly, width - urx, ury, width - llx] diff --git a/spec/lib/templates/find_acro_fields_spec.rb b/spec/lib/templates/find_acro_fields_spec.rb index 754b15e0b1..fe51ff5c3e 100644 --- a/spec/lib/templates/find_acro_fields_spec.rb +++ b/spec/lib/templates/find_acro_fields_spec.rb @@ -9,19 +9,20 @@ let(:field_rect) { [100.0, 100.0, 300.0, 130.0] } # x0, y0, x1, y1 (PDF bottom-left origin) let(:attachment) { instance_double(ActiveStorage::Attachment, uuid: SecureRandom.uuid) } - def build_pdf(rotate: nil) + def build_pdf(rotate: nil, media_box: nil, crop_box: nil, rect: nil) doc = HexaPDF::Document.new - page = doc.pages.add([0, 0, page_w, page_h]) + page = doc.pages.add(media_box || [0, 0, page_w, page_h]) + page[:CropBox] = crop_box if crop_box page[:Rotate] = rotate if rotate && rotate != 0 form = doc.acro_form(create: true) - form.create_text_field('name').create_widget(page, Rect: field_rect.dup) + form.create_text_field('name').create_widget(page, Rect: (rect || field_rect).dup) doc end - def extract_area(rotate: nil) - pdf = build_pdf(rotate:) + def extract_area(rotate: nil, media_box: nil, crop_box: nil, rect: nil) + pdf = build_pdf(rotate:, media_box:, crop_box:, rect:) fields = described_class.call(pdf, attachment, '') expect(fields).not_to be_empty fields.first[:areas].first @@ -99,5 +100,39 @@ def expect_area(area, expected_x:, expected_y:, expected_w:, expected_h:) expected_h: 200 / page_w ) end + + it 'maps fields for /Rotate 90 when MediaBox is not 0-origin' do + # Offsets [10, 20]; absolute Rect chosen so correct_coordinates yields the + # same 0-origin rect as the base cases ([100, 100, 300, 130]). Extents-based + # rotation only matches HexaPDF flatten after that normalization. + media_box = [10.0, 20.0, 10.0 + page_w, 20.0 + page_h] + rect = [110.0, 120.0, 310.0, 150.0] + area = extract_area(rotate: 90, media_box:, rect:) + + expect_area( + area, + expected_x: 100 / page_h, + expected_y: (page_w - 512) / page_w, + expected_w: 30 / page_h, + expected_h: 200 / page_w + ) + end + + it 'uses CropBox extents for /Rotate 90 when CropBox differs from MediaBox' do + # media_box up top is CropBox whenever present. Larger MediaBox must not + # change width/height or the post-correct_coordinates origin. + media_box = [0.0, 0.0, 700.0, 900.0] + crop_box = [10.0, 20.0, 10.0 + page_w, 20.0 + page_h] + rect = [110.0, 120.0, 310.0, 150.0] + area = extract_area(rotate: 90, media_box:, crop_box:, rect:) + + expect_area( + area, + expected_x: 100 / page_h, + expected_y: (page_w - 512) / page_w, + expected_w: 30 / page_h, + expected_h: 200 / page_w + ) + end end end