From 849630ec0385329570eab40bb9b1571dfe92e62a Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 30 Aug 2026 14:40:11 +0200 Subject: [PATCH] Put two piles of scans together, and put a document on one sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two verbs a page-manipulation library should have had. Interleave takes pages from each document in turn. It is what a single-sided feeder leaves behind: a stack run through twice comes out as two files, the fronts and the backs, which is one document taken apart. Merging them end to end gives every front then every back; this gives the document. A shorter pile simply runs out, which is what a scanner leaves when the last sheet is single-sided. OnePage stacks every page onto one sheet, one below the other. A page is a unit of paper, not a unit of reading: a receipt, a chat log and a web page cut into A4 are one thing a printer divided, and this puts them back so that what is read scrolls rather than turns. The sheet is as wide as the widest page and as tall as all of them stacked, and narrower pages are centred, because a column that jumps from side to side is harder to read than one that does not. The order is asserted rather than looked at. A PDF's origin is at the bottom, so the FIRST page takes the LARGEST offset and the last sits at zero. Reversed, every page is still there and the document is upside down — which no count of pages would catch. And poppler was asked rather than ourselves. Three pages carrying a mark at a different height each, stacked and rendered: the marks come out at 28%, 48% and 70% of the sheet, against 26.7%, 46.7% and 68.3% computed from where they were put. Parity: BentoPDF's "Alternate & Mix Pages" and "Combine to Single Page". 100% statement coverage, go vet and -race clean, nine cross-compile targets. --- interleave.go | 102 +++++++++++++++++++ interleave_test.go | 247 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 interleave.go create mode 100644 interleave_test.go diff --git a/interleave.go b/interleave.go new file mode 100644 index 0000000..28073c5 --- /dev/null +++ b/interleave.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import "fmt" + +// Interleave takes pages from each document in turn — the first of this, the +// first of the next, and round again — until every one of them is used up. +// +// It is what a duplex scanner leaves behind. A stack fed through a +// single-sided feeder twice comes out as two files: the fronts in order, and +// the backs, which is the same document taken apart. Merging them end to end +// gives every front then every back; this gives the document. +// +// The backs are usually in reverse: the stack is turned over as a stack, so +// the last sheet's back is scanned first. Reverse that one before interleaving +// and the pages land in the right order. +// +// Documents of different lengths do not stop it. A shorter one simply runs +// out, and the rest of the longer one follows in order — which is what a +// scanner leaves when the last sheet is single-sided. +func (d *Doc) Interleave(others ...*Doc) error { + all := append([]*Doc{d}, others...) + total := 0 + for _, o := range all { + if o == nil { + return fmt.Errorf("ops: cannot interleave with nothing") + } + total += len(o.pages) + } + if total == 0 { + return fmt.Errorf("ops: there are no pages to interleave") + } + longest := 0 + for _, o := range all { + if len(o.pages) > longest { + longest = len(o.pages) + } + } + out := make([]Page, 0, total) + for i := 0; i < longest; i++ { + for _, o := range all { + if i < len(o.pages) { + out = append(out, o.pages[i]) + } + } + } + d.pages = out + for _, o := range others { + if d.version < o.version { + d.version = o.version + } + if d.info == nil && o.info != nil { + d.info = o.info + } + } + return nil +} + +// OnePage puts every page on a single sheet, one below the other. +// +// A page is a unit of paper, not a unit of reading: a receipt, a chat log and +// a web page cut into A4 are one thing that a printer divided. This puts them +// back, so what is read scrolls rather than turns. +// +// The sheet is as wide as the widest page and as tall as all of them stacked. +// Pages narrower than the widest are centred, because a column of text that +// jumps from side to side is harder to read than one that does not. +func (d *Doc) OnePage() error { + if len(d.pages) == 0 { + return fmt.Errorf("ops: an empty document has nothing to stack") + } + if len(d.pages) == 1 { + return nil + } + width, height := 0.0, 0.0 + sizes := make([][2]float64, len(d.pages)) + for i, p := range d.pages { + sizes[i] = d.effectiveSize(p) + if sizes[i][0] > width { + width = sizes[i][0] + } + height += sizes[i][1] + } + if width <= 0 || height <= 0 { + return fmt.Errorf("ops: these pages have no size to stack") + } + // Laid out from the bottom up, because that is where a PDF's origin is and + // the first page belongs at the top. + tiles := make([]tile, 0, len(d.pages)) + y := height + for i, p := range d.pages { + y -= sizes[i][1] + x := (width - sizes[i][0]) / 2 + tiles = append(tiles, tile{from: p, matrix: [6]float64{1, 0, 0, 1, x, y}}) + } + d.pages = []Page{{tiles: tiles, size: [2]float64{width, height}}} + return nil +} diff --git a/interleave_test.go b/interleave_test.go new file mode 100644 index 0000000..b691732 --- /dev/null +++ b/interleave_test.go @@ -0,0 +1,247 @@ +// Copyright (c) 2026, the go-pdfkit/ops authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package ops + +import ( + "strings" + "testing" + + "github.com/go-pdfkit/reader" +) + +// pagesNamed builds a document whose pages say what the caller asks, so the +// order they come out in can be read rather than inferred. +func pagesNamed(t *testing.T, names ...string) *Doc { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + kids := make(reader.Array, 0, len(names)) + for _, n := range names { + kids = append(kids, w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pagesRef, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte(n)}), + })) + } + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), "Kids": kids, + "Count": reader.Integer(len(names)), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(100), reader.Integer(200)}}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef})}) + if err != nil { + t.Fatal(err) + } + d, err := Open(out) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestTheFrontsAndTheBacksBecomeTheDocument(t *testing.T) { + // What a single-sided feeder leaves behind: the fronts in one file and the + // backs in another. Merging them end to end gives every front then every + // back; this gives the document. + fronts := pagesNamed(t, "1", "3", "5") + backs := pagesNamed(t, "2", "4", "6") + if err := fronts.Interleave(backs); err != nil { + t.Fatal(err) + } + if got := strings.Join(written(t, fronts), " "); got != "1 2 3 4 5 6" { + t.Errorf("came out as %q", got) + } +} + +func TestAShorterPileSimplyRunsOut(t *testing.T) { + // Which is what a scanner leaves when the last sheet is single-sided. + fronts := pagesNamed(t, "1", "3", "5") + backs := pagesNamed(t, "2", "4") + if err := fronts.Interleave(backs); err != nil { + t.Fatal(err) + } + if got := strings.Join(written(t, fronts), " "); got != "1 2 3 4 5" { + t.Errorf("came out as %q", got) + } +} + +func TestMoreThanTwoPiles(t *testing.T) { + a, b, c := pagesNamed(t, "1", "4"), pagesNamed(t, "2", "5"), pagesNamed(t, "3", "6") + if err := a.Interleave(b, c); err != nil { + t.Fatal(err) + } + if got := strings.Join(written(t, a), " "); got != "1 2 3 4 5 6" { + t.Errorf("came out as %q", got) + } +} + +func TestInterleavingWithNothing(t *testing.T) { + for _, tc := range []struct { + name string + build func(t *testing.T) (*Doc, []*Doc) + reason string + }{ + {"a document that is not there", func(t *testing.T) (*Doc, []*Doc) { + return pagesNamed(t, "1"), []*Doc{nil} + }, "cannot interleave with nothing"}, + {"no pages anywhere", func(t *testing.T) (*Doc, []*Doc) { + return New(), []*Doc{New()} + }, "no pages to interleave"}, + } { + t.Run(tc.name, func(t *testing.T) { + d, others := tc.build(t) + err := d.Interleave(others...) + if err == nil { + t.Fatal("it went ahead anyway") + } + if !strings.Contains(err.Error(), tc.reason) { + t.Errorf("it said %q", err) + } + }) + } +} + +func TestInterleavingOneDocumentWithItself(t *testing.T) { + // Nothing to interleave with is not an error: the document is already in + // the order it is in. + d := pagesNamed(t, "1", "2") + if err := d.Interleave(); err != nil { + t.Fatal(err) + } + if got := strings.Join(written(t, d), " "); got != "1 2" { + t.Errorf("came out as %q", got) + } +} + +func TestEveryPageOnOneSheet(t *testing.T) { + // A page is a unit of paper, not a unit of reading. + d := pagesNamed(t, "1", "2", "3") + if err := d.OnePage(); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + src, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + if src.PageCount() != 1 { + t.Fatalf("%d pages, want 1", src.PageCount()) + } + page, err := src.Page(1) + if err != nil { + t.Fatal(err) + } + box, _ := reader.ToArray(resolved(src, page.Get("MediaBox"))) + w, _ := reader.ToFloat(resolved(src, box[2])) + h, _ := reader.ToFloat(resolved(src, box[3])) + // As wide as the widest page, as tall as all of them stacked. + if w != 100 || h != 600 { + t.Errorf("the sheet is %g by %g, want 100 by 600", w, h) + } + // And the pages are stacked from the top down. A PDF's origin is at the + // bottom, so the FIRST page has the largest offset and the last sits at + // zero. Getting this backwards prints the document upside down and every + // page is still there, which is why it is asserted rather than looked at. + content, err := src.PageContent(1) + if err != nil { + t.Fatal(err) + } + want := []string{ + "q 1 0 0 1 0 400 cm /Tile0 Do Q", + "q 1 0 0 1 0 200 cm /Tile1 Do Q", + "q 1 0 0 1 0 0 cm /Tile2 Do Q", + } + at := -1 + for _, line := range want { + i := strings.Index(string(content), line) + if i < 0 { + t.Fatalf("%q is not in the sheet:\n%s", line, content) + } + if i < at { + t.Errorf("%q comes out of order", line) + } + at = i + } +} + +func TestANarrowerPageIsCentred(t *testing.T) { + // A column of text that jumps from side to side is harder to read than one + // that does not. + d := New() + d.Blank(100, 50) + d.Blank(60, 50) + if err := d.OnePage(); err != nil { + t.Fatal(err) + } + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + src, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + content, err := src.PageContent(1) + if err != nil { + t.Fatal(err) + } + // (100-60)/2 = 20 across, and below the first page. + if !strings.Contains(string(content), "1 0 0 1 20 0 cm") { + t.Errorf("the narrow page is not centred:\n%s", content) + } +} + +func TestStackingWhatCannotBeStacked(t *testing.T) { + if err := New().OnePage(); err == nil { + t.Error("an empty document was stacked") + } + // One page is already one page. + d := pagesNamed(t, "1") + if err := d.OnePage(); err != nil { + t.Fatal(err) + } + if got := strings.Join(written(t, d), " "); got != "1" { + t.Errorf("came out as %q", got) + } +} + +func TestStackingPagesOfNoSize(t *testing.T) { + d := New() + d.Blank(0, 0) + d.Blank(0, 0) + if err := d.OnePage(); err == nil { + t.Error("pages of no size were stacked") + } +} + +func TestWhatTheOtherDocumentsBringWithThem(t *testing.T) { + // Interleaving two documents makes one, and it has to be a document a + // reader of the newer of them can open: the version is the higher of the + // two, as it is when they are merged end to end. + older := pagesNamed(t, "1") + newer := pagesNamed(t, "2") + older.version, newer.version = "1.4", "2.0" + if err := older.Interleave(newer); err != nil { + t.Fatal(err) + } + if older.version != "2.0" { + t.Errorf("the joined document says version %q", older.version) + } + + // And a document that says nothing about itself takes what the other says, + // rather than losing it. + blank := pagesNamed(t, "1") + titled := pagesNamed(t, "2") + blank.info = nil + titled.info = reader.Dict{"Title": reader.String("a title")} + if err := blank.Interleave(titled); err != nil { + t.Fatal(err) + } + if blank.info == nil { + t.Error("the title was dropped on the way") + } +}