From 619c4a5b5c136bb3b318124110d56e27e0af34fa Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:28:05 -0400 Subject: [PATCH 1/2] Fix annotation rectangle under a rotated transformation matrix `_convertRect` in lib/mixins/annotations.js reassigned `x1` on the line before it used `x1` to compute `y1`, and did the same with `x2` and `y2`, so each corner's y came out of the already transformed x. It also mapped only two of the four corners, which cannot describe the axis aligned `Rect` that ISO 32000-1 12.5.2 asks for once the matrix rotates or skews. Both defects surface together the moment `doc.rotate()` is in effect: a quarter turn of a 100x100 box about its own top left corner produced `/Rect [0 872 100 772]`, which is 200 points above the content and inverted, instead of `/Rect [0 672 100 772]`. Reported in #1153. Transform all four corners and return their bounding box. Every matrix that keeps the axes aligned and both scale factors positive - the default page matrix, `translate` and positive `scale` - yields the same two extremes as before, so existing output does not move; a mirroring matrix now returns a normalized `[llx lly urx ury]` rectangle as ISO 32000-1 7.9.5 requires. --- CHANGELOG.md | 1 + lib/mixins/annotations.js | 28 ++++++++++++++++++++-------- tests/unit/annotations.spec.js | 22 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) 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 From c3011b4a8f42827683c9db6718b92c0b9be37a4d Mon Sep 17 00:00:00 2001 From: Max Freedom Pollard <272618364+MaxFreedomPollard@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:35:00 -0400 Subject: [PATCH 2/2] Simplify the annotation rectangle bounds computation Drop the inner transform helper, the corner array and its two maps, and the four Math.min/Math.max calls. Transforming the corner at (x, y) once along with the width and height edge vectors gives the same bounding box: every corner is that corner plus either edge or both, so the low bound adds the negative part of each edge and the high bound the positive part. Six multiplications instead of eight and no intermediate arrays, with the same rectangle as before for every matrix, down to floating point association order. --- lib/mixins/annotations.js | 42 ++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/lib/mixins/annotations.js b/lib/mixins/annotations.js index b2fd0218..ee82ab2b 100644 --- a/lib/mixins/annotations.js +++ b/lib/mixins/annotations.js @@ -178,33 +178,29 @@ export default { return this.annotate(x, y, w, h, annotationOptions); }, - _convertRect(x1, y1, w, h) { - // flip y1 and y2 - const y2 = y1; - y1 += h; - - // make x2 - const 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; - const transform = (x, y) => [m0 * x + m2 * y + m4, m1 * x + m3 * y + m5]; + 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 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 + // 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. - const corners = [ - transform(x1, y1), - transform(x2, y1), - transform(x2, y2), - transform(x1, y2), + 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), ]; - 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)]; }, };