Skip to content

ops: turn a page by any angle, growing the paper to hold it - #83

Merged
tannevaled merged 1 commit into
mainfrom
feat/turn-by-any-angle
Nov 9, 2026
Merged

tannevaled merged 1 commit into
mainfrom
feat/turn-by-any-angle

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Doc.Turn and pdfops turn: the arbitrary-angle half that Rotate refuses.
Rotate writes a number on the page — /Rotate, a multiple of ninety — and
Turn rewrites the page onto a sheet of its own.

pdfops turn -by 30 tilted.pdf turned.pdf
pdfops turn -pages 2,5-7 -by -1.5 scan.pdf nudged.pdf

Ported from BentoPDF's rotate-custom (docs/tools/rotate-custom.md,
src/js/logic/rotate-custom-page.ts), which is the remaining Rotate by
Custom Degrees
row of the gap table.

The paper grows and nothing is cut off

That is the whole difference from Deskew. A deskew is a correction of a
fraction of a degree on a page whose text sits well inside its margins, so it
keeps the paper it was given; a turn by thirty degrees loses two corners if the
paper does not grow with it.

The sheet is the turned page's bounding box — w·|cos θ| + h·|sin θ| across,
w·|sin θ| + h·|cos θ| down, the reference's own arithmetic — and the page is
centred on it. The test maps all four corners for angles from −350° to 350°:
each must land inside the sheet, and each edge must be touched, or the box
is bigger than it needs to be. A box built from the wrong pair of terms is right
at 0°, 45° and 90° and wrong everywhere between, which is why it is swept rather
than sampled.

An angle that is a multiple of ninety goes through /Rotate and nothing else
happens: no transformation, no new content stream, a page whose text was
selectable stays selectable. Cheaper, and what the reference does too.

⛔ Positive is clockwise, and the reference contradicts itself about it

rotate-custom.md says "Positive values rotate clockwise". Its code writes a
multiple of ninety as /Rotate — which ISO 32000-1 Table 30 defines as degrees
clockwise — and sends every other angle through pdf-lib's drawPage, which
concatenates [cos sin −sin cos]: a counter-clockwise turn in a space whose
y counts upwards. The two arms turn opposite ways, and the seam is at exactly
90°, where the tool switches from one to the other.

Both arms are clockwise here. That is what its documentation promises, what the
specification means by a rotation, and what Doc.Rotate in this package has
always done — a library in which Turn(90) and Rotate(90) disagreed would be
a worse trap than the one being ported.

The gate is a render, and it was proved by breaking it

Every assertion written on how far a page turned survives a sign error: a
page turned 30° the wrong way is exactly as far from straight as one turned 30°
the right way. So the test asks where the ink ended up. A mark at the top of
a square page must come back at the right-hand edge after +90°, the way a
clock's hand goes from twelve to three.

The seam is crossed on purpose, at 89°, 90° and 91°, with two questions,
because neither alone can see it:

  • how far across the page the mark is cannot tell 89° from 91° — but it
    catches a quarter-turn arm going the other way, which would put the mark at
    nine o'clock;
  • how far down it is does separate them, and catches the matrix arm.

Both go red when the sign in turnOntoSheet is flipped. That was run, not
predicted.

⛔ quarterTurns refuses an angle whose quarter-circles do not fit in an int
rather than converting: nothing a person types reaches 2³¹ degrees, but a file
can hold anything, and that is the kind of arithmetic that turns a document into
a panic.

ok github.com/go-pdfkit/ops 14.9s coverage: 100.0% of statements,
covblocks: 0 blocks uncovered.

🤖 Generated with Claude Code

Doc.Turn and `pdfops turn`, the arbitrary-angle half that Rotate refuses.
Rotate writes a number on the page -- /Rotate, a multiple of ninety -- and Turn
rewrites the page onto a sheet of its own.

⛔ THE PAPER GROWS AND NOTHING IS CUT OFF, which is the whole difference from
Deskew. A deskew is a correction of a fraction of a degree on a page whose text
sits well inside its margins, so it keeps the paper it was given; a turn by
thirty degrees loses two corners if the paper does not grow with it. The sheet
is the turned page's bounding box -- w|cos| + h|sin| across, w|sin| + h|cos|
down, BentoPDF's own arithmetic -- and the page is centred on it. The test maps
all four corners for angles from -350 to 350 degrees: each must land inside the
sheet, and each edge must be touched, or the box is bigger than it needs to be.
A box built from the wrong pair of terms is right at 0, 45 and 90 degrees and
wrong everywhere between, which is why it is swept rather than sampled.

An angle that is a multiple of ninety goes through /Rotate and nothing else
happens: no transformation, no new content stream, a page whose text was
selectable stays selectable. That is both cheaper and what the reference does.

⛔⛔ POSITIVE IS CLOCKWISE, AND THE REFERENCE CONTRADICTS ITSELF ABOUT IT.
rotate-custom.md says "Positive values rotate clockwise". Its code writes a
multiple of ninety as /Rotate, which ISO 32000-1 Table 30 defines as degrees
CLOCKWISE, and sends every other angle through pdf-lib's drawPage, which
concatenates [cos sin -sin cos] -- a COUNTER-clockwise turn in a space whose y
counts upwards. The two arms turn opposite ways and the seam is at exactly 90.
Both are clockwise here: it is what its documentation promises, what the
specification means by a rotation, and what Doc.Rotate has always done.

The gate is a render, because every assertion written on how FAR a page turned
survives a sign error. A mark at the top of a square page must come back at the
right-hand edge after +90, the way a clock's hand goes from twelve to three --
and the seam is crossed on purpose, at 89, 90 and 91 degrees, with two
questions: how far ACROSS the page the mark is cannot tell 89 from 91 but
catches a quarter-turn arm going the other way, and how far DOWN it is does
separate them and catches the matrix arm. Both go red when the sign in
turnOntoSheet is flipped; that was run.

⛔ And quarterTurns refuses an angle whose quarter-circles do not fit in an int
instead of converting: nothing a person types reaches 2^31 degrees, but a file
can hold anything.

ok github.com/go-pdfkit/ops 14.9s coverage: 100.0% of statements; covblocks: 0
blocks uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 5954839 into main Nov 9, 2026
1 check passed
@tannevaled
tannevaled deleted the feat/turn-by-any-angle branch November 9, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant