From b34396ca066c7213ff7b103557bfa79403ee3f66 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 9 Nov 2026 20:45:40 +0100 Subject: [PATCH] ops: turn a page by any angle, growing the paper to hold it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 35 ++++ cmd/pdfops/run.go | 27 +++ cmd/pdfops/run_test.go | 61 +++++++ turn.go | 119 ++++++++++++++ turn_test.go | 364 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 606 insertions(+) create mode 100644 turn.go create mode 100644 turn_test.go diff --git a/README.md b/README.md index a8696ad..324e29f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 8fa78ea..44fe844 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -39,6 +39,7 @@ var commands = []command{ {"delete", "-pages ", "drop the pages a range names", runDelete}, {"reverse", " ", "put the pages in the opposite order", runReverse}, {"rotate", "-pages -by ", "turn pages by a multiple of ninety", runRotate}, + {"turn", "-pages -by ", "turn pages by any angle, clockwise, growing the paper so no corner is cut off", runTurn}, {"crop", "-pages -box ", "set the visible area, in points", runCrop}, {"resize", "[-pages ] (-box | -to [-landscape]) ", "set how big the pages are", runResize}, {"move", "-from -to ", "take a page out and put it back elsewhere", runMove}, @@ -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, " "); 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") diff --git a/cmd/pdfops/run_test.go b/cmd/pdfops/run_test.go index 717283c..ce06a43 100644 --- a/cmd/pdfops/run_test.go +++ b/cmd/pdfops/run_test.go @@ -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") diff --git a/turn.go b/turn.go new file mode 100644 index 0000000..ee87ce0 --- /dev/null +++ b/turn.go @@ -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), + } +} diff --git a/turn_test.go b/turn_test.go new file mode 100644 index 0000000..f353990 --- /dev/null +++ b/turn_test.go @@ -0,0 +1,364 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "bytes" + "fmt" + "math" + "strings" + "testing" + + "github.com/go-pdfkit/reader" + "github.com/go-pdfkit/render" +) + +// pipPagePDF is a page of the given size with a small black square at one +// place on it, named by the fraction of the page it sits at. One mark is all +// a turn needs: where it ends up says which way the page went round, and a +// second mark would only give a sign error somewhere to hide. +func pipPagePDF(t *testing.T, w, h, atX, atY float64) []byte { + t.Helper() + var c bytes.Buffer + fmt.Fprintf(&c, "0 g %g %g 20 20 re f\n", atX*w-10, atY*h-10) + wr := reader.NewWriter("1.7") + pagesRef := wr.Reserve() + content := wr.Add(&reader.Stream{Dict: reader.Dict{}, Raw: c.Bytes()}) + page := wr.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, "Contents": content}) + wr.Put(pagesRef, reader.Dict{ + "Type": reader.Name("Pages"), "Kids": reader.Array{page}, "Count": reader.Integer(1), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Real(w), reader.Real(h)}, + }) + root := wr.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef}) + out, err := wr.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + return out +} + +// whereTheInkIs renders the first page of a document and reports where the +// black mark sits, as a fraction of the drawing in each direction — across +// from the left, and DOWN from the top, which is how a reader looking at the +// page sees it. +func whereTheInkIs(t *testing.T, pdf []byte) (acrossFrac, downFrac float64) { + t.Helper() + src, err := reader.Open(pdf) + if err != nil { + t.Fatal(err) + } + img, err := render.Page(src, 1, render.Options{DPI: 72}) + if err != nil { + t.Fatal(err) + } + b := img.Bounds() + // The CENTRE OF MASS of the ink rather than its bounding box: a rotated + // square has corners the renderer softens, and a box built from whichever + // pixel the antialiasing happened to darken moves about by a pixel or two + // from one angle to the next. The mean does not. + var sumX, sumY, n float64 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + c := img.At(x, y) + if c.R > 100 || c.G > 100 || c.B > 100 { + continue + } + sumX, sumY, n = sumX+float64(x), sumY+float64(y), n+1 + } + } + if n == 0 { + t.Fatal("the rendered page has no black mark on it at all") + } + return (sumX / n) / float64(b.Dx()), (sumY / n) / float64(b.Dy()) +} + +// ⛔⛔ WHICH WAY ROUND, MEASURED ON A RENDER. Every assertion written on how far +// a page turned survives a sign error — a page turned 30° the wrong way is as +// far from straight as one turned 30° the right way — so the only honest +// question is where the ink ENDED UP. The mark starts at the top of the page, +// in the middle: turned clockwise by ninety it must be at the right-hand edge, +// the way a clock's hand goes from twelve to three. +func TestTurningClockwisePutsTheMarkWhereAClockWould(t *testing.T) { + // ⛔ THE FRACTIONS ARE OF THE SHEET THE PAGE ENDS UP ON, not of the one it + // started on. At 45° a square page's sheet is √2 times as wide, so a mark + // four tenths of the way out from the centre comes back two tenths of the + // way out: 0.4/√2 points of travel over a sheet √2 times as long. Reading + // those two tenths as 0.4/√2 is the mistake this comment exists to stop, + // and it is the first thing that went wrong when this was written. + const atFortyFive = 0.4 / math.Sqrt2 / math.Sqrt2 // = 0.2 + for _, tc := range []struct { + by float64 + wantAcross, wantDown float64 + what string + }{ + {0, 0.5, 0.1, "twelve o'clock, untouched"}, + {90, 0.9, 0.5, "three o'clock"}, + {180, 0.5, 0.9, "six o'clock"}, + {-90, 0.1, 0.5, "nine o'clock"}, + {45, 0.5 + atFortyFive, 0.5 - atFortyFive, "half past one"}, + {-45, 0.5 - atFortyFive, 0.5 - atFortyFive, "half past ten"}, + } { + // A SQUARE page, so that the fractions below are the same question + // whichever way it went round: on an oblong, a quarter turn changes + // which side the fraction is of and the arithmetic stops being readable. + d, err := Open(pipPagePDF(t, 400, 400, 0.5, 0.9)) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("all", tc.by); err != nil { + t.Fatalf("%+g°: %v", tc.by, err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + across, down := whereTheInkIs(t, out) + if math.Abs(across-tc.wantAcross) > 0.03 || math.Abs(down-tc.wantDown) > 0.03 { + t.Errorf("turned %+g° (%s): the mark is %.2f across and %.2f down, want %.2f and %.2f "+ + "— a mark on the wrong side means the page turned the wrong way", + tc.by, tc.what, across, down, tc.wantAcross, tc.wantDown) + } + } +} + +// ⛔⛔ THE SEAM. A quarter turn is written as the page's own /Rotate and a +// degree either side of it goes through a matrix, so the two arms meet at +// exactly ninety — which is where the reference this is ported from turns one +// way on one side and the other way on the other. Nothing but a measurement +// across the seam can catch that, so this asks where the mark is at 89, 90 and +// 91 degrees and insists it moves in one direction. +func TestTurningAQuarterAgreesWithRotate(t *testing.T) { + at := func(by float64) (float64, float64) { + t.Helper() + d, err := Open(pipPagePDF(t, 400, 400, 0.5, 0.9)) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("all", by); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + return whereTheInkIs(t, out) + } + // ⛔ TWO QUESTIONS, because neither alone crosses the seam. A mark a degree + // short of three o'clock and a mark a degree past it are the same distance + // ACROSS the page, so that coordinate cannot tell 89° from 91° — but it is + // the one that catches a quarter-turn arm going the other way, which would + // put the mark at nine o'clock. How far DOWN the page it is does separate + // 89° from 91°, and it is what catches the matrix arm going the other way. + acrossBefore, downBefore := at(89) + acrossQuarter, downQuarter := at(90) + acrossAfter, downAfter := at(91) + if math.Abs(acrossQuarter-acrossBefore) > 0.02 || math.Abs(acrossQuarter-acrossAfter) > 0.02 { + t.Errorf("the mark is %.3f across at 89°, %.3f at 90° and %.3f at 91°: the quarter-turn arm "+ + "and the matrix arm disagree about which way round a page goes", + acrossBefore, acrossQuarter, acrossAfter) + } + if !(downBefore < downQuarter && downQuarter < downAfter) { + t.Errorf("the mark is %.3f down at 89°, %.3f at 90° and %.3f at 91°: a page turned further "+ + "clockwise must carry a mark that started at the top further down", + downBefore, downQuarter, downAfter) + } + // And the quarter turn really is the cheap arm: no transformation, no new + // content stream, the page's own /Rotate and nothing else. + d, err := Open(pipPagePDF(t, 400, 300, 0.5, 0.9)) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("all", 90); err != nil { + t.Fatal(err) + } + if d.pages[0].tiles != nil { + t.Error("a quarter turn put the page inside a form instead of setting /Rotate") + } + if d.pages[0].rotate != 90 { + t.Errorf("/Rotate is %d after a quarter turn, want 90", d.pages[0].rotate) + } +} + +// ⛔ NOTHING IS CUT OFF, which is the whole difference from a deskew. The sheet +// grows to the bounding box of the turned page, so all four corners land inside +// it — and it is TIGHT, so each edge is touched. Swept over angles rather than +// tried at one, because a bounding box built from the wrong pair of terms is +// right at 0°, 90° and 45° and wrong everywhere between. +func TestTheSheetGrowsToHoldEveryCornerAndNoMore(t *testing.T) { + size := [2]float64{400, 250} + for by := -350.0; by <= 350; by += 7 { + sheet, m := turnOntoSheet(by, size) + var lo, hi [2]float64 + lo = [2]float64{math.Inf(1), math.Inf(1)} + hi = [2]float64{math.Inf(-1), math.Inf(-1)} + for _, corner := range [4][2]float64{{0, 0}, {size[0], 0}, {size[0], size[1]}, {0, size[1]}} { + x := m[0]*corner[0] + m[2]*corner[1] + m[4] + y := m[1]*corner[0] + m[3]*corner[1] + m[5] + lo[0], lo[1] = math.Min(lo[0], x), math.Min(lo[1], y) + hi[0], hi[1] = math.Max(hi[0], x), math.Max(hi[1], y) + } + const slack = 1e-9 + if lo[0] < -slack || lo[1] < -slack { + t.Fatalf("turned %+g°: a corner is at (%.6f, %.6f), off the bottom-left of the sheet", + by, lo[0], lo[1]) + } + if hi[0] > sheet[0]+slack || hi[1] > sheet[1]+slack { + t.Fatalf("turned %+g°: a corner is at (%.6f, %.6f) on a %g by %g sheet: it would be cut off", + by, hi[0], hi[1], sheet[0], sheet[1]) + } + if math.Abs(lo[0]) > 1e-9 || math.Abs(lo[1]) > 1e-9 || + math.Abs(hi[0]-sheet[0]) > 1e-9 || math.Abs(hi[1]-sheet[1]) > 1e-9 { + t.Fatalf("turned %+g°: the content occupies (%.6f,%.6f)-(%.6f,%.6f) of a %g by %g sheet, "+ + "which is bigger than it needs to be", by, lo[0], lo[1], hi[0], hi[1], sheet[0], sheet[1]) + } + } +} + +// The sheet of a turned page is the one BentoPDF computes, which is the part of +// its logic worth porting exactly: w·|cos| + h·|sin| across, w·|sin| + h·|cos| +// down. +func TestTheTurnedSheetIsTheReferencesOwnBoundingBox(t *testing.T) { + d, err := Open(pipPagePDF(t, 200, 100, 0.5, 0.5)) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("all", 45); err != nil { + t.Fatal(err) + } + want := (200 + 100) / math.Sqrt2 + got := d.pages[0].size + if math.Abs(got[0]-want) > 1e-9 || math.Abs(got[1]-want) > 1e-9 { + t.Errorf("a 200 by 100 page turned 45° sits on a %g by %g sheet, want %g by %g", + got[0], got[1], want, want) + } +} + +// A turn adds to what the page already carries, as the reference's does: it +// reads the page's current rotation and turns by current + asked. +func TestTurningTwiceAddsUp(t *testing.T) { + d, err := Open(pipPagePDF(t, 400, 400, 0.5, 0.9)) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("all", 90); err != nil { + t.Fatal(err) + } + if err := d.Turn("all", 90); err != nil { + t.Fatal(err) + } + if d.pages[0].rotate != 180 { + t.Fatalf("two quarter turns left /Rotate at %d, want 180", d.pages[0].rotate) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + _, down := whereTheInkIs(t, out) + if down < 0.8 { + t.Errorf("after two quarter turns the mark is %.2f down the page; it started at the top "+ + "and two quarters put it at the bottom", down) + } + // And a turn by a matrix on top of a quarter turn keeps both: the sheet is + // the turned page's bounding box, not the original page's. + if err := d.Turn("all", 30); err != nil { + t.Fatal(err) + } + if d.pages[0].tiles == nil { + t.Fatal("a thirty-degree turn did not compose the page") + } + want := 400*math.Cos(30*math.Pi/180) + 400*math.Sin(30*math.Pi/180) + if math.Abs(d.pages[0].size[0]-want) > 1e-9 { + t.Errorf("the sheet is %g across, want %g", d.pages[0].size[0], want) + } +} + +// A page turned by a whole circle, or by nothing, is the page that came in: no +// transformation, no re-encoded content, the same bytes. +func TestAFullTurnChangesNothing(t *testing.T) { + in := pipPagePDF(t, 400, 300, 0.5, 0.9) + plain, err := Open(in) + if err != nil { + t.Fatal(err) + } + was, err := plain.Bytes() + if err != nil { + t.Fatal(err) + } + for _, by := range []float64{0, 360, -720} { + d, err := Open(in) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("all", by); err != nil { + t.Fatalf("%+g°: %v", by, err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, was) { + t.Errorf("turning by %+g° changed the file", by) + } + } +} + +// Only the pages the range names are turned. +func TestTurningOnePageLeavesTheOthers(t *testing.T) { + d, err := Open(simple(t, 2)) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("2", 30); err != nil { + t.Fatal(err) + } + if d.pages[0].tiles != nil { + t.Error("page 1 was turned and nobody asked") + } + if d.pages[1].tiles == nil { + t.Error("page 2 was not turned") + } +} + +func TestTurnRefusesWhatIsNotAnAngle(t *testing.T) { + for _, by := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} { + d, err := Open(pipPagePDF(t, 400, 300, 0.5, 0.9)) + if err != nil { + t.Fatal(err) + } + err = d.Turn("all", by) + if err == nil { + t.Fatalf("%v was accepted as an angle", by) + } + if !strings.Contains(err.Error(), "is not an angle") { + t.Errorf("%v: %v", by, err) + } + } +} + +func TestTurnRefusesARangeItCannotRead(t *testing.T) { + d, err := Open(pipPagePDF(t, 400, 300, 0.5, 0.9)) + if err != nil { + t.Fatal(err) + } + if err := d.Turn("fourteen", 30); err == nil { + t.Fatal("a range of nonsense was accepted") + } +} + +// ⛔ An angle whose quarter-circles do not fit in an int goes through the +// matrix arm rather than through the int conversion, which would wrap and turn +// the page by something nobody asked for. +func TestAnAbsurdAngleDoesNotWrapAround(t *testing.T) { + if _, ok := quarterTurns(90 * (math.MaxInt32 + 1024.0)); ok { + t.Error("an angle past the reach of an int was offered as a count of quarter turns") + } + if q, ok := quarterTurns(-270); !ok || q != -270 { + t.Errorf("quarterTurns(-270) = %d, %v", q, ok) + } + if _, ok := quarterTurns(90.5); ok { + t.Error("90.5° was called a whole number of quarter turns") + } +}