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..ee82ab2b 100644 --- a/lib/mixins/annotations.js +++ b/lib/mixins/annotations.js @@ -178,21 +178,29 @@ export default { return this.annotate(x, y, w, h, annotationOptions); }, - _convertRect(x1, y1, w, h) { - // flip y1 and y2 - let y2 = y1; - y1 += h; - - // make x2 - let x2 = x1 + w; - - // apply current transformation matrix to points + _convertRect(x, y, w, h) { + // apply the current transformation matrix to the corner at (x, y) and to + // the two edge vectors that reach the other three corners from it 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 ox = m0 * x + m2 * y + m4; + const oy = m1 * x + m3 * y + m5; + const wx = m0 * w; + const wy = m1 * w; + const hx = m2 * h; + const hy = m3 * h; + + // ISO 32000-1 12.5.2 defines Rect as an axis aligned rectangle in default + // user space, so under a rotated or skewed matrix it is the bounding box of + // all four corners rather than one opposing pair. Each corner is (ox, oy) + // plus either edge or both, so the low bound adds the negative part of each + // edge and the high bound the positive part. That also returns the + // normalized [llx lly urx ury] ISO 32000-1 7.9.5 asks for when the matrix + // mirrors an axis. + return [ + ox + (wx < 0 ? wx : 0) + (hx < 0 ? hx : 0), + oy + (wy < 0 ? wy : 0) + (hy < 0 ? hy : 0), + ox + (wx > 0 ? wx : 0) + (hx > 0 ? hx : 0), + oy + (wy > 0 ? wy : 0) + (hy > 0 ? hy : 0), + ]; }, }; 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