-
Notifications
You must be signed in to change notification settings - Fork 0
CP-14715 - Fix auto-detected form fields on rotated PDFs #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -77,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 | ||
|
|
@@ -124,6 +138,20 @@ 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. | ||
| # 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: the "mirrors Half a sentence in the comment saves the next person the trip through |
||
| 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) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| # 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, media_box: nil, crop_box: nil, rect: nil) | ||
| doc = HexaPDF::Document.new | ||
| 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: (rect || field_rect).dup) | ||
|
|
||
| doc | ||
| end | ||
|
|
||
| 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 | ||
| 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 | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One thing the swap doesn't reach, worth naming so it doesn't look covered:
cell_wfurther down at line 91.After a 90 or 270,
wis the visual width, which for a comb field whose cells ran along media-x is now the short axis. Sow / page_width / MaxLendivides the wrong side, and comb text on a rotated page still gets cells in the wrong place.Niche enough that I wouldn't grow this PR for it, and it was equally broken before. But the description says fields line up, so I'd either carve out the comb case or drop a note here.