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") + } +}