Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions lib/templates/find_acro_fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

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_w further down at line 91.

After a 90 or 270, w is the visual width, which for a comb field whose cells ran along media-x is now the short axis. So w / page_width / MaxLen divides 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.

end

x = x0
y = y0
w = x1 - x0
Expand All @@ -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
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: the "mirrors Page#rotate(0, flatten: true)" line is accurate, and I'd add the caveat that makes it true. Hexapdf builds its matrix from box edges; this takes extents, and the two coincide only because the caller hands over coordinates already normalized to a 0-origin box.

Half a sentence in the comment saves the next person the trip through correct_coordinates that I just took.

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)

Expand Down
138 changes: 138 additions & 0 deletions spec/lib/templates/find_acro_fields_spec.rb
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
Loading