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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pdfops merge whole.pdf part1.pdf part2.pdf
pdfops select -pages 3-7,last report.pdf extract.pdf
pdfops delete -pages even scan.pdf fronts.pdf
pdfops rotate -pages all -by 90 sideways.pdf upright.pdf
pdfops turn -by 30 tilted.pdf turned.pdf
pdfops crop -box 20,20,575,820 wide.pdf trimmed.pdf
pdfops resize -to a4 odd-sizes.pdf a4.pdf
pdfops resize -to a4 -landscape slides.pdf wide.pdf
Expand Down Expand Up @@ -217,6 +218,40 @@ text lines, and below about two samples per line pitch it returns an angle that
is simply wrong. See `SkewOptions.MaxPixels` in go-images for the measured
table.

`turn` turns pages by **any** angle, which is the half `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.

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

⛔ **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 would lose two corners if the paper did not grow with
it. The sheet is the turned page's bounding box —
`w·|cos θ| + h·|sin θ|` across, `w·|sin θ| + h·|cos θ|` down — and the page is
centred on it, which the tests check by mapping 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.

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

⛔ **Positive is clockwise, and the reference this is ported from contradicts
itself about that.** BentoPDF's `rotate-custom` documents *"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°. Here both are clockwise, and
the gate is a render: 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 —
measured at 89°, 90° and 91° so that the seam itself is crossed.

⛔ `divide` makes the **same grid** as `poster` and is the opposite operation.
`poster` keeps every piece the size of the page it came from, so it
**enlarges**: the pieces taped together make one page the size of a wall.
Expand Down
27 changes: 27 additions & 0 deletions cmd/pdfops/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ var commands = []command{
{"delete", "-pages <range> <in.pdf> <out.pdf>", "drop the pages a range names", runDelete},
{"reverse", "<in.pdf> <out.pdf>", "put the pages in the opposite order", runReverse},
{"rotate", "-pages <range> -by <degrees> <in.pdf> <out.pdf>", "turn pages by a multiple of ninety", runRotate},
{"turn", "-pages <range> -by <degrees> <in.pdf> <out.pdf>", "turn pages by any angle, clockwise, growing the paper so no corner is cut off", runTurn},
{"crop", "-pages <range> -box <l,b,r,t> <in.pdf> <out.pdf>", "set the visible area, in points", runCrop},
{"resize", "[-pages <range>] (-box <l,b,r,t> | -to <name> [-landscape]) <in.pdf> <out.pdf>", "set how big the pages are", runResize},
{"move", "-from <page> -to <page> <in.pdf> <out.pdf>", "take a page out and put it back elsewhere", runMove},
Expand Down Expand Up @@ -284,6 +285,32 @@ func runRotate(c *context, args []string) error {
return save(d, fs.Arg(1))
}

// runTurn is the arbitrary-angle sibling of runRotate. It is a verb of its own
// rather than a flag on that one because the two do different things to the
// document: a quarter turn is a number written on the page, and any other
// angle rewrites the page onto a bigger sheet. A reader who typed -by 30 on
// `rotate` and got a refusal would learn more from a verb that says what it
// does than from a flag that quietly changed the kind of file they get.
func runTurn(c *context, args []string) error {
fs := flags("turn")
pages := fs.String("pages", "all", "the pages to turn")
by := fs.Float64("by", 0, "degrees clockwise; any angle, not only a multiple of ninety")
if err := fs.Parse(args); err != nil {
return err
}
if err := wantArgs(fs, 2, "<in.pdf> <out.pdf>"); err != nil {
return err
}
d, err := c.open(fs.Arg(0))
if err != nil {
return err
}
if err := d.Turn(*pages, *by); err != nil {
return err
}
return save(d, fs.Arg(1))
}

func runCrop(c *context, args []string) error {
fs := flags("crop")
pages := fs.String("pages", "all", "the pages to crop")
Expand Down
61 changes: 61 additions & 0 deletions cmd/pdfops/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,67 @@ func TestRotate(t *testing.T) {
}
}

// `turn` takes the angle `rotate` refuses, and the file it writes says so: a
// page turned by thirty degrees sits on a bigger sheet than it started on,
// which is the visible half of "no corner is cut off".
func TestTurn(t *testing.T) {
in := fixture(t, 2)
out := filepath.Join(t.TempDir(), "out.pdf")
// The sizes come from the command's own `info`, so the measurement is one
// a person at a terminal could repeat.
sizes := func(path string) []string {
t.Helper()
code, printed, msg := exec("info", path)
if code != 0 {
t.Fatalf("info: code %d: %s", code, msg)
}
var out []string
for _, line := range strings.Split(printed, "\n") {
if strings.HasPrefix(line, "page ") {
out = append(out, line)
}
}
if len(out) != 2 {
t.Fatalf("info printed %d page line(s)", len(out))
}
return out
}
was := sizes(in)
if code, _, msg := exec("turn", "-pages", "1", "-by", "30", in, out); code != 0 {
t.Fatalf("code %d: %s", code, msg)
}
now := sizes(out)
if now[0] == was[0] {
t.Errorf("a page turned thirty degrees is still %q: the sheet did not grow, so its corners "+
"are off the paper", now[0])
}
// The page nobody named keeps the paper it had.
if now[1] != was[1] {
t.Errorf("page 2 went from %q to %q, and it was not named", was[1], now[1])
}
// A quarter turn is the cheap arm, and the command reaches it too.
if code, _, msg := exec("turn", "-pages", "all", "-by", "90", in, out); code != 0 {
t.Fatalf("code %d: %s", code, msg)
}
b, _ := os.ReadFile(out)
d, _ := ops.Open(b)
if got, _ := d.Rotation(1); got != 90 {
t.Errorf("rotation = %d after a quarter turn", got)
}
for _, args := range [][]string{
{"turn", in},
{"turn", "-nosuchflag", in, out},
{"turn", "-by", "NaN", in, out},
{"turn", "-pages", "fourteen", "-by", "30", in, out},
{"turn", "-by", "30", "/no/such/file.pdf", out},
{"turn", "-by", "30", in, "/no/such/dir/x.pdf"},
} {
if code, _, _ := exec(args...); code != 1 {
t.Errorf("%v should fail", args)
}
}
}

func TestCrop(t *testing.T) {
in := fixture(t, 1)
out := filepath.Join(t.TempDir(), "out.pdf")
Expand Down
119 changes: 119 additions & 0 deletions turn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026, the go-pdfkit/ops authors
// All rights reserved.
//
// SPDX-License-Identifier: BSD-3-Clause

package ops

import (
"fmt"
"math"
)

// Turning a page by an angle that is not a quarter of a circle.
//
// # What this is parity with
//
// BentoPDF's rotate-custom, read at docs/tools/rotate-custom.md and
// src/js/logic/rotate-custom-page.ts. Its contract is one angle a page, added
// to whatever the page already carries: "Rotate PDF pages by any arbitrary
// angle, not just 90-degree increments."
//
// Its geometry is ported exactly, and it is the geometry that matters: the
// sheet grows to the bounding box of the turned page —
//
// W = w·|cos θ| + h·|sin θ| H = w·|sin θ| + h·|cos θ|
//
// — and the page is placed so that its centre lands on the centre of that
// sheet. ⛔ NOTHING IS CUT OFF, which is the whole difference from [Doc.Deskew]:
// a deskew is a correction of a quarter of a degree on a scan whose text sits
// well inside the margins, so it keeps the paper it was given; a turn by
// thirty degrees would lose two corners of the page if the paper did not grow
// with it.
//
// ⛔ THE REFERENCE CONTRADICTS ITSELF ABOUT THE DIRECTION, AND THIS FOLLOWS ITS
// DOCUMENTATION. rotate-custom.md says "Positive values rotate clockwise". Its
// code has two arms: a total that is a multiple of ninety is written as the
// page's /Rotate, which ISO 32000-1 Table 30 defines as "the number of degrees
// by which the page shall be rotated CLOCKWISE when displayed"; everything else
// goes through pdf-lib's drawPage with `rotate: degrees(total)`, which
// concatenates [cos sin −sin cos], a COUNTER-clockwise turn in a space whose y
// counts upwards. So the two arms turn opposite ways, and the seam is at
// exactly 90°, where the tool switches from one to the other.
//
// Positive is clockwise here, on both arms. That is what its documentation
// promises, what the PDF specification means by a rotation, and what
// [Doc.Rotate] in this package has always done — and a library in which Turn(90)
// and Rotate(90) disagreed would be a worse trap than the one being ported.
// TestTurningAQuarterAgreesWithRotate is the gate.

// Turn turns the pages the range names by an arbitrary angle in degrees,
// clockwise, adding to whatever rotation they already carry. The sheet grows
// to hold the turned page, so no corner is cut off.
//
// An angle that is a multiple of ninety is written as the page's own /Rotate
// and nothing else happens: no transformation, no new content stream, and a
// page whose text was selectable stays selectable. That is both cheaper and
// more faithful than turning it by a matrix, and it is what the reference does
// too.
func (d *Doc) Turn(spec string, degrees float64) error {
if math.IsNaN(degrees) || math.IsInf(degrees, 0) {
return fmt.Errorf("ops: %g is not an angle to turn a page by", degrees)
}
nums, err := ParseRange(spec, len(d.pages))
if err != nil {
return err
}
for _, n := range nums {
if quarters, ok := quarterTurns(degrees); ok {
d.pages[n-1].rotate = normaliseRotation(d.pages[n-1].rotate + quarters)
continue
}
p := d.pages[n-1]
sheet, m := turnOntoSheet(degrees, d.effectiveSize(p))
d.pages[n-1] = Page{size: sheet, tiles: []tile{{from: p, matrix: m}}}
}
return nil
}

// quarterTurns reports the whole quarter-circles in an angle, and whether the
// angle is made of nothing else.
//
// ⛔ NOT math.Mod(degrees, 90) == 0 ON ITS OWN: that is true for an angle whose
// quarters do not fit in an int, and the caller is about to convert. Nothing a
// person types reaches 2³¹ degrees, but a file can hold anything and this is
// the kind of arithmetic that turns a document into a panic.
func quarterTurns(degrees float64) (int, bool) {
turns := degrees / 90
if turns != math.Trunc(turns) || math.Abs(turns) > math.MaxInt32 {
return 0, false
}
return int(turns) * 90, true
}

// turnOntoSheet is the sheet a page of the given size needs once it is turned
// clockwise by degrees, and the matrix that puts the page on it.
//
// ⛔ THE SIGN IS REASONED THROUGH ONCE, HERE, AND MEASURED SOMEWHERE ELSE. A
// content-stream matrix [cos sin −sin cos] turns counter-clockwise, because PDF
// user space counts y upwards; so a CLOCKWISE turn by θ is that matrix built
// from −θ. Every test written on an absolute error survives a sign error —
// recovering +5° from a page tilted −5° looks the same as the right answer — so
// the gate renders the page and asks which corner the ink ended up in. See
// TestTurningClockwisePutsTheMarkWhereAClockWould.
func turnOntoSheet(degrees float64, size [2]float64) ([2]float64, [6]float64) {
s, c := math.Sincos(-degrees * math.Pi / 180)
w, h := size[0], size[1]
sheet := [2]float64{
w*math.Abs(c) + h*math.Abs(s),
w*math.Abs(s) + h*math.Abs(c),
}
// The page's centre, turned about the origin, is moved to the centre of the
// sheet. Every other point follows it, which is what makes the bounding box
// above tight: the two corners that reach furthest in each direction land
// exactly on the edges.
return sheet, [6]float64{c, s, -s, c,
sheet[0]/2 - (w/2*c - h/2*s),
sheet[1]/2 - (w/2*s + h/2*c),
}
}
Loading
Loading