diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88535e2..5c566d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: check-latest: true cache: true + - name: Install poppler, the foreign judge the render tests print by + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends poppler-utils + - name: go vet run: go vet ./... diff --git a/poster.go b/poster.go new file mode 100644 index 0000000..88824dc --- /dev/null +++ b/poster.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "fmt" + "math" +) + +// Poster spreads each page over across×down sheets, so that the sheets +// printed and taped edge to edge make one page the size of a wall. It is the +// inverse of [Doc.NUp], which puts several pages on one sheet. +// +// Every sheet is the size of the page it came from. A poster of an A4 +// document therefore prints on A4, and each piece has the page's own +// proportions — pieces of some other shape do not tape back into the page they +// were cut from. Because the sheets keep their shape, a wall of them is only +// the page's shape again when across and down are equal; otherwise the page is +// enlarged as far as it will go inside the wall and centred, and the sheets at +// the edges carry the white that is left over. +// +// The sheets meet exactly: nothing is repeated from one to the next, and no +// margin is left for taping. An overlap would have to be a constant here, +// because Poster is told how many sheets to use and not how wide the printer's +// unprinted border is; too small a guess still leaves a seam, too large a one +// loses more of the poster to trimming than it saves, and neither could be +// turned off. Meeting exactly is also the only arrangement that is exactly +// undone: every part of the page lands on one sheet and one only, so the +// pieces butt together — taped from behind — with nothing to cut away and +// nothing printed twice. +// +// The sheets come out in reading order: left to right, top to bottom, the +// top-left one first, so a printed pile can be laid out on a table in the +// order it came off the printer. +func (d *Doc) Poster(across, down int) error { + if across < 1 || down < 1 { + return fmt.Errorf("ops: a poster %d sheets across and %d down makes no sense", across, down) + } + if len(d.pages) == 0 { + return fmt.Errorf("ops: an empty document has nothing to spread") + } + if across == 1 && down == 1 { + return nil + } + out := make([]Page, 0, len(d.pages)*across*down) + for _, p := range d.pages { + size := d.effectiveSize(p) + if size[0] <= 0 || size[1] <= 0 { + return fmt.Errorf("ops: a page with no size cannot be spread over sheets") + } + // The wall of sheets is across×down pages wide and tall, so the + // largest the page can be drawn on it without changing shape is the + // smaller of the two counts. What is left over is shared between the + // two opposite edges. + scale := math.Min(float64(across), float64(down)) + left := (float64(across) - scale) * size[0] / 2 + bottom := (float64(down) - scale) * size[1] / 2 + for row := 0; row < down; row++ { + for col := 0; col < across; col++ { + // A sheet shows the part of the wall it covers, so the page is + // shifted by the sheet's own corner. Rows are counted from the + // top, the way the sheets are read, while a PDF counts up from + // the bottom: row zero is the topmost band of the wall. + x := left - float64(col)*size[0] + y := bottom - float64(down-1-row)*size[1] + out = append(out, Page{ + size: size, + tiles: []tile{{from: p, matrix: [6]float64{scale, 0, 0, scale, x, y}}}, + }) + } + } + } + d.pages = out + return nil +} diff --git a/poster_render_test.go b/poster_render_test.go new file mode 100644 index 0000000..222d790 --- /dev/null +++ b/poster_render_test.go @@ -0,0 +1,198 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "bytes" + "fmt" + "image" + "image/png" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/go-pdfkit/reader" +) + +// This file checks a poster the way a person would: it prints one and looks at +// the sheets. poppler does the printing and the looking, because a package +// that agrees with itself about where it put something has proved nothing. It +// is skipped where pdftoppm is not installed. + +// posterPalette gives each cell of a three-by-three page a colour of its own, +// so that a rendered sheet says which cell it came from. +var posterPalette = [9][3]float64{ + {1, 0, 0}, {0, 0.6, 0}, {0, 0, 1}, + {1, 0.6, 0}, {0.6, 0, 0.6}, {0, 0.6, 0.6}, + {0.4, 0.2, 0}, {1, 0, 1}, {0.3, 0.3, 0.3}, +} + +// markedPage builds a page 300 by 600 points divided into a three-by-three +// grid of 100 by 200 cells. Each cell carries a block of its own colour, +// centred, and a small black pip tucked into the cell's top-left corner. The +// colour says which cell a sheet shows; the pip says which way up it is. +func markedPage(t *testing.T) []byte { + t.Helper() + var c bytes.Buffer + for row := 0; row < 3; row++ { + for col := 0; col < 3; col++ { + x, top := float64(col)*100, float64(3-row)*200 + p := posterPalette[row*3+col] + fmt.Fprintf(&c, "%g %g %g rg %g %g 40 80 re f\n", p[0], p[1], p[2], x+30, top-140) + fmt.Fprintf(&c, "0 0 0 rg %g %g 12 12 re f\n", x+4, top-16) + } + } + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + content := w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: c.Bytes()}) + page := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, "Contents": content}) + w.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.Integer(300), reader.Integer(600)}, + }) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + return out +} + +// inkBox reports the bounding box of the pixels a predicate accepts, and how +// many there were. +func inkBox(img image.Image, want func(r, g, b uint32) bool) (box [4]int, n int) { + b := img.Bounds() + box = [4]int{b.Max.X, b.Max.Y, b.Min.X, b.Min.Y} + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bb, _ := img.At(x, y).RGBA() + if !want(r>>8, g>>8, bb>>8) { + continue + } + n++ + box[0], box[1] = min(box[0], x), min(box[1], y) + box[2], box[3] = max(box[2], x), max(box[3], y) + } + } + return box, n +} + +// closeTo accepts pixels within a channel or so of a colour, which is what an +// anti-aliased renderer leaves in the middle of a solid block. +func closeTo(want [3]uint32) func(r, g, b uint32) bool { + off := func(a, b uint32) uint32 { + if a > b { + return a - b + } + return b - a + } + return func(r, g, b uint32) bool { + return off(r, want[0]) < 12 && off(g, want[1]) < 12 && off(b, want[2]) < 12 + } +} + +// boxNear compares two boxes, allowing a pixel either way: which pixel a +// renderer calls the last one of an edge is its own business. +func boxNear(got, want [4]int) bool { + for i := range got { + if got[i] < want[i]-1 || got[i] > want[i]+1 { + return false + } + } + return true +} + +func eightBit(c [3]float64) [3]uint32 { + return [3]uint32{uint32(c[0]*255 + 0.5), uint32(c[1]*255 + 0.5), uint32(c[2]*255 + 0.5)} +} + +func TestPosterJudgedByPoppler(t *testing.T) { + if _, err := exec.LookPath("pdftoppm"); err != nil { + // Skipping is right on a machine that has no poppler, and wrong in CI, + // where the workflow installs one: a judge that quietly absents itself + // is no better than no judge, and this is the only thing here that + // reads the output rather than the arithmetic that produced it. + if os.Getenv("CI") != "" { + t.Fatal("pdftoppm is not installed, and CI is meant to have it") + } + t.Skip("pdftoppm is not installed") + } + d, err := Open(markedPage(t)) + if err != nil { + t.Fatal(err) + } + if err := d.Poster(3, 3); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + file := filepath.Join(dir, "poster.pdf") + if err := os.WriteFile(file, out, 0o600); err != nil { + t.Fatal(err) + } + // A point to the pixel, so a coordinate in the file is a coordinate in the + // picture and the sums below can be read. + cmd := exec.Command("pdftoppm", "-png", "-r", "72", "-cropbox", file, filepath.Join(dir, "sheet")) + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("pdftoppm: %v: %s", err, b) + } + for i := 0; i < 9; i++ { + f, err := os.Open(filepath.Join(dir, fmt.Sprintf("sheet-%d.png", i+1))) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(f) + f.Close() + if err != nil { + t.Fatal(err) + } + if b := img.Bounds(); b.Dx() != 300 || b.Dy() != 600 { + t.Fatalf("sheet %d rendered %dx%d, want the page's own 300x600", i+1, b.Dx(), b.Dy()) + } + want := eightBit(posterPalette[i]) + + // Sheet i shows cell i, three times life size: the block that was 40 + // by 80 in the middle of a cell is 120 by 240 in the middle of a + // sheet. Reading order is the claim being tested — that this is cell + // i's colour and not some other cell's. + box, n := inkBox(img, closeTo(want)) + if n == 0 { + t.Errorf("sheet %d shows nothing of cell %d's colour %v", i+1, i+1, want) + continue + } + if !boxNear(box, [4]int{90, 180, 209, 419}) { + t.Errorf("sheet %d: block at %v, want (90,180)-(209,419)", i+1, box) + } + for j, q := range posterPalette { + other := eightBit(q) + if j == i || other == want { + continue + } + // Anti-aliasing blends an edge towards white and can pass close to + // another entry of the palette, so a handful of pixels is not + // evidence. A block is twenty-eight thousand of them. + if _, m := inkBox(img, closeTo(other)); m > 500 { + t.Errorf("sheet %d also shows cell %d's colour %v, in %d pixels", i+1, j+1, other, m) + } + } + + // The pip sat in its cell's top-left corner, so it must sit in the + // sheet's. A poster laid out from the bottom has every piece present + // and every one of them in the wrong place; this is what says so. + pip, n := inkBox(img, func(r, g, b uint32) bool { return r < 40 && g < 40 && b < 40 }) + if n == 0 { + t.Errorf("sheet %d has no pip", i+1) + continue + } + if !boxNear(pip, [4]int{12, 12, 47, 47}) { + t.Errorf("sheet %d: pip at %v, want (12,12)-(47,47) — the top-left corner", i+1, pip) + } + } +} diff --git a/poster_test.go b/poster_test.go new file mode 100644 index 0000000..72c34e6 --- /dev/null +++ b/poster_test.go @@ -0,0 +1,285 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "testing" +) + +// region is the part of the source page one poster sheet shows, in the page's +// own coordinates: left, bottom, right, top, with the origin at the foot of +// the page the way a PDF counts. +type region [4]float64 + +// regionOf works out which part of the page a sheet shows, by asking where the +// sheet's own corners land once the tile's matrix is undone. A sheet is +// size[0] by size[1] with its corner at the origin, and the matrix scales and +// shifts the page onto it, so the inverse is a division and a subtraction. +func regionOf(m [6]float64, size [2]float64) region { + return region{ + -m[4] / m[0], + -m[5] / m[3], + (size[0] - m[4]) / m[0], + (size[1] - m[5]) / m[3], + } +} + +func nearRegion(a, b region) bool { + for i := range a { + if !near(a[i], b[i]) { + return false + } + } + return true +} + +// posterSheets writes a document and reads back, for every sheet, its media +// box and the single tile it places. +func posterSheets(t *testing.T, d *Doc) ([][4]float64, []placed) { + t.Helper() + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + boxes := make([][4]float64, 0, d.PageCount()) + tiles := make([]placed, 0, d.PageCount()) + for i := 1; i <= d.PageCount(); i++ { + mb, placed := sheetOf(t, out, i) + if len(placed) != 1 { + t.Fatalf("sheet %d places %d tiles, want one", i, len(placed)) + } + boxes = append(boxes, mb) + tiles = append(tiles, placed[0]) + } + return boxes, tiles +} + +// TestPosterSpreadsOverFourSheets pins the whole of a two-by-two poster: the +// sheets are the page's own size, the page is drawn at twice its size, and the +// four quarters come out in reading order. +func TestPosterSpreadsOverFourSheets(t *testing.T) { + d, err := Open(simple(t, 1)) + if err != nil { + t.Fatal(err) + } + if err := d.Poster(2, 2); err != nil { + t.Fatal(err) + } + if got := d.PageCount(); got != 4 { + t.Fatalf("PageCount() = %d, want 4", got) + } + boxes, tiles := posterSheets(t, d) + + // The source pages are 100 by 200. Every sheet is that size again: a piece + // of a poster that is not the shape of the page will not tape back into + // it. + for i, mb := range boxes { + if mb != [4]float64{0, 0, 100, 200} { + t.Errorf("sheet %d is %v, want the page's own 0 0 100 200", i+1, mb) + } + } + + // The matrices, exactly. A PDF's origin is at the foot of the sheet, so + // the sheet that shows the top of the page is the one that pushes the page + // furthest down — which is why these two numbers are negative on the first + // sheet and zero on the last. + want := [][6]float64{ + {2, 0, 0, 2, 0, -200}, // top left + {2, 0, 0, 2, -100, -200}, // top right + {2, 0, 0, 2, 0, 0}, // bottom left + {2, 0, 0, 2, -100, 0}, // bottom right + } + for i, tile := range tiles { + if tile.content != "page 1" { + t.Errorf("sheet %d carries %q", i+1, tile.content) + } + for k := range want[i] { + if !near(tile.matrix[k], want[i][k]) { + t.Errorf("sheet %d matrix = %v, want %v", i+1, tile.matrix, want[i]) + break + } + } + } + + // The same claim said the other way round, in the page's coordinates: what + // each sheet actually shows. Read as a table this is the poster laid out + // on the floor, and it is upside down if the top and bottom rows swap. + quarters := []region{ + {0, 100, 50, 200}, // top left + {50, 100, 100, 200}, // top right + {0, 0, 50, 100}, // bottom left + {50, 0, 100, 100}, // bottom right + } + for i, tile := range tiles { + if got := regionOf(tile.matrix, [2]float64{100, 200}); !nearRegion(got, quarters[i]) { + t.Errorf("sheet %d shows %v of the page, want %v", i+1, got, quarters[i]) + } + } +} + +// TestPosterReadingOrderIsLeftToRightTopToBottom checks the order on a grid +// that is not square, where a transposed loop would still give the right +// number of sheets and the right set of regions. +func TestPosterReadingOrderIsLeftToRightTopToBottom(t *testing.T) { + d, err := Open(simple(t, 1)) + if err != nil { + t.Fatal(err) + } + if err := d.Poster(2, 3); err != nil { + t.Fatal(err) + } + if got := d.PageCount(); got != 6 { + t.Fatalf("PageCount() = %d, want 6", got) + } + _, tiles := posterSheets(t, d) + + // Two across and three down: the page grows twice, which is as far as it + // goes across, and the half sheet left over vertically is shared between + // the top and the bottom. So the page occupies the middle two thirds and a + // bit of the sheets above and below it, and the top band shows only the + // top half-sheet of white plus the top of the page. + // + // In the page's own coordinates each sheet is 50 wide and 100 tall, and + // the six of them run from y=250 down to y=-50. + want := []region{ + {0, 150, 50, 250}, {50, 150, 100, 250}, + {0, 50, 50, 150}, {50, 50, 100, 150}, + {0, -50, 50, 50}, {50, -50, 100, 50}, + } + for i, tile := range tiles { + if got := regionOf(tile.matrix, [2]float64{100, 200}); !nearRegion(got, want[i]) { + t.Errorf("sheet %d shows %v of the page, want %v", i+1, got, want[i]) + } + } + // Said plainly, so the table above cannot be wrong in the same way as the + // code: each sheet is left of or above the one after it. + for i := 1; i < len(tiles); i++ { + a := regionOf(tiles[i-1].matrix, [2]float64{100, 200}) + b := regionOf(tiles[i].matrix, [2]float64{100, 200}) + if a[3] < b[3] || (near(a[3], b[3]) && a[0] >= b[0]) { + t.Errorf("sheet %d at %v does not read before sheet %d at %v", i, a, i+1, b) + } + } +} + +// TestPosterKeepsTheSheetShapeWhenTheGridDoesNot checks that the page is +// enlarged only as far as it fits, so a wide grid does not stretch it. +func TestPosterKeepsTheSheetShapeWhenTheGridDoesNot(t *testing.T) { + d, err := Open(simple(t, 1)) + if err != nil { + t.Fatal(err) + } + if err := d.Poster(3, 1); err != nil { + t.Fatal(err) + } + boxes, tiles := posterSheets(t, d) + if len(boxes) != 3 { + t.Fatalf("got %d sheets", len(boxes)) + } + for i, tile := range tiles { + // One sheet tall means the page cannot grow at all, and the three + // sheets across leave a whole sheet of white on either side of it. + if !near(tile.matrix[0], 1) || !near(tile.matrix[3], 1) { + t.Errorf("sheet %d scale = %v, want no enlargement", i+1, tile.matrix) + } + if !near(tile.matrix[0], tile.matrix[3]) { + t.Errorf("sheet %d is stretched: %v", i+1, tile.matrix) + } + } + want := []float64{100, 0, -100} + for i, tile := range tiles { + if !near(tile.matrix[4], want[i]) || !near(tile.matrix[5], 0) { + t.Errorf("sheet %d at (%g,%g), want (%g,0)", i+1, tile.matrix[4], tile.matrix[5], want[i]) + } + } +} + +// TestPosterSpreadsEveryPage checks that a document of several pages gives +// each of them its own set of sheets, in order. +func TestPosterSpreadsEveryPage(t *testing.T) { + d, err := Open(simple(t, 3)) + if err != nil { + t.Fatal(err) + } + if err := d.Poster(2, 1); err != nil { + t.Fatal(err) + } + _, tiles := posterSheets(t, d) + want := []string{"page 1", "page 1", "page 2", "page 2", "page 3", "page 3"} + if len(tiles) != len(want) { + t.Fatalf("got %d sheets, want %d", len(tiles), len(want)) + } + for i, tile := range tiles { + if tile.content != want[i] { + t.Errorf("sheet %d carries %q, want %q", i+1, tile.content, want[i]) + } + } +} + +// TestPosterFollowsARotatedPage checks that a page turned on its side is +// spread over sheets of the shape a reader sees, not of the shape the file +// stored. +func TestPosterFollowsARotatedPage(t *testing.T) { + d, err := Open(simple(t, 1)) + if err != nil { + t.Fatal(err) + } + if err := d.SetRotation("1", 90); err != nil { + t.Fatal(err) + } + if err := d.Poster(2, 2); err != nil { + t.Fatal(err) + } + boxes, tiles := posterSheets(t, d) + for i, mb := range boxes { + if mb != [4]float64{0, 0, 200, 100} { + t.Errorf("sheet %d is %v, want the turned page's 0 0 200 100", i+1, mb) + } + } + // The rotation is baked into the tile's own form, so the placing matrix is + // the same as it would be for an upright page of that shape. + want := [][2]float64{{0, -100}, {-200, -100}, {0, 0}, {-200, 0}} + for i, tile := range tiles { + if !near(tile.matrix[4], want[i][0]) || !near(tile.matrix[5], want[i][1]) { + t.Errorf("sheet %d at (%g,%g), want %v", i+1, tile.matrix[4], tile.matrix[5], want[i]) + } + } +} + +// TestPosterOfOneSheetChangesNothing checks that the do-nothing grid leaves +// the pages borrowed rather than wrapping each in a form. +func TestPosterOfOneSheetChangesNothing(t *testing.T) { + d, err := Open(simple(t, 2)) + if err != nil { + t.Fatal(err) + } + if err := d.Poster(1, 1); err != nil { + t.Fatal(err) + } + if got := written(t, d); !equal(got, pages(1, 2)) { + t.Errorf("Poster(1, 1) gave %v", got) + } +} + +func TestPosterRefusesWhatMakesNoSense(t *testing.T) { + d, err := Open(simple(t, 1)) + if err != nil { + t.Fatal(err) + } + for _, grid := range [][2]int{{0, 2}, {2, 0}, {-1, -1}} { + if err := d.Poster(grid[0], grid[1]); err == nil { + t.Errorf("Poster(%d, %d) was allowed", grid[0], grid[1]) + } + } + if err := New().Poster(2, 2); err == nil { + t.Error("a poster of an empty document was allowed") + } + sizeless := New() + sizeless.Blank(0, 0) + if err := sizeless.Poster(2, 2); err == nil { + t.Error("a poster of a page with no size was allowed") + } +}