From 1d022090e7dd8bae7c5f8919d4e4e07fe21dd91a Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 30 Aug 2026 17:13:51 +0200 Subject: [PATCH] Print a page bigger than the paper it has to go on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poster spreads each page over across×down sheets, so the sheets printed and taped together make one page the size of a wall. It is the inverse of NUp, which is the verb this library already had for the other direction. Every sheet is the size of the page it came from. That is the whole of the constraint: a piece of a poster that is not the shape of the page will not tape back into it, and a printer asked for something other than the paper in its tray does its own scaling, which is exactly what a poster must not have done to it. So an A4 document posters onto A4. The page is then enlarged as far as it goes inside the wall of sheets and centred, which is min(across, down) times — so a square grid uses every sheet whole, and an oblong one leaves white at two opposite edges rather than stretching the page to fill it. No overlap. Poster is told how many sheets and not how wide the printer's unprinted border is, so an overlap would have to be a constant guessing at a printer nobody here can see: too small and the seam still gaps, too large and more of the poster goes to trimming than the margin saves, and neither could be turned off. Meeting exactly is also the only arrangement that is exactly undone — every part of the page on one sheet and one only. The order is asserted rather than counted. A PDF's origin is at the BOTTOM, so the sheet showing the TOP of the page is the one that pushes the page furthest down, and the four matrices of a two-by-two poster end in -200, -200, 0, 0 and not the other way about. Get it wrong and every piece is present, every sheet is the right size, and the poster is upside down — which no count of pages would catch. The tests pin the matrices, and pin them twice: once as six numbers, and once turned back into the region of the page each sheet shows, so that the table can be read as the poster laid out on the floor. Those tests were checked by breaking the code on purpose. Rows counted from the bottom, the loops transposed, the scale taken from across alone, the leftover not centred, the scale made non-uniform: each is caught, by 6 to 18 assertions. And poppler was asked rather than ourselves, since a package that agrees with itself about where it put something has proved nothing. A page of nine coloured blocks, one to a cell, each with a black pip in its cell's top-left corner, is postered three by three and rendered by pdftoppm: sheet i carries cell i's colour and no other cell's, the block lands in the middle at three times life size, and the pip is in the sheet's own top-left corner. Flip the rows and six sheets report the wrong colour; transpose the loops and six do. It is the first test here to shell out at all, and CI now installs poppler so that it runs there rather than skipping: the runner image does not carry poppler-utils, so as written it would have gone quiet in CI and said nothing about it. It skips on a machine that has no poppler and FAILS when CI does not, because a judge that quietly absents itself is no better than no judge. 100% statement coverage, go vet and -race clean, nine cross-compile targets. --- .github/workflows/ci.yml | 3 + poster.go | 78 +++++++++++ poster_render_test.go | 198 +++++++++++++++++++++++++++ poster_test.go | 285 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 564 insertions(+) create mode 100644 poster.go create mode 100644 poster_render_test.go create mode 100644 poster_test.go 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") + } +}