diff --git a/CHANGELOG.md b/CHANGELOG.md index 630a9272..a42a5e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Unreleased - Add a `hidden` option to form annotation methods, for a field that should start hidden (e.g. one an interactive action reveals later) instead of the usual default of visible and printable +- Fix annotations placed under `doc.rotate()` marking the wrong area, because `_convertRect` derived each corner's y from the already transformed x and mapped only two of the four corners, so the rectangle a viewer makes interactive did not follow the rotated content. Fixes #1153 ### [v0.20.2] - 2026-08-29 diff --git a/lib/mixins/annotations.js b/lib/mixins/annotations.js index e1464b10..b2fd0218 100644 --- a/lib/mixins/annotations.js +++ b/lib/mixins/annotations.js @@ -180,19 +180,31 @@ export default { _convertRect(x1, y1, w, h) { // flip y1 and y2 - let y2 = y1; + const y2 = y1; y1 += h; // make x2 - let x2 = x1 + w; + const x2 = x1 + w; // apply current transformation matrix to points const [m0, m1, m2, m3, m4, m5] = this._ctm; - x1 = m0 * x1 + m2 * y1 + m4; - y1 = m1 * x1 + m3 * y1 + m5; - x2 = m0 * x2 + m2 * y2 + m4; - y2 = m1 * x2 + m3 * y2 + m5; - - return [x1, y1, x2, y2]; + const transform = (x, y) => [m0 * x + m2 * y + m4, m1 * x + m3 * y + m5]; + + // ISO 32000-1 12.5.2 defines Rect as an axis aligned rectangle in default + // user space, so a rotated or skewed matrix needs the bounding box of all + // four transformed corners. Two corners are enough only while the matrix + // keeps the axes aligned, and picking the extremes also keeps the result + // normalized ([llx lly urx ury], ISO 32000-1 7.9.5) when the matrix + // mirrors an axis. + const corners = [ + transform(x1, y1), + transform(x2, y1), + transform(x2, y2), + transform(x1, y2), + ]; + const xs = corners.map((corner) => corner[0]); + const ys = corners.map((corner) => corner[1]); + + return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]; }, }; diff --git a/tests/unit/annotations.spec.js b/tests/unit/annotations.spec.js index 3b238f63..e25cecca 100644 --- a/tests/unit/annotations.spec.js +++ b/tests/unit/annotations.spec.js @@ -101,6 +101,28 @@ describe('Annotations', () => { }); }); + describe('rectangle under a transformation matrix', () => { + test('covers the whole rotated area', () => { + const docData = logData(document); + + // Turning a 100x100 box a quarter turn about its own top left corner + // swings it from x 100..200 across to x 0..100 and leaves it spanning + // y 672..772 in default user space. + document.rotate(90, { origin: [100, 20] }); + document.link(100, 20, 100, 100, 'http://www.example.com'); + + expect(docData.join('\n')).toContain('/Rect [0 672 100 772]'); + }); + + test('leaves an untransformed rectangle alone', () => { + const docData = logData(document); + + document.link(100, 20, 100, 100, 'http://www.example.com'); + + expect(docData.join('\n')).toContain('/Rect [100 672 200 772]'); + }); + }); + describe('undefined option values', () => { // `doc.annotate()` passes arbitrary dictionary keys straight through by // design, so unlike the acroform options there is no call site at which an