diff --git a/doc.go b/doc.go index ddae8ae..dd12120 100644 --- a/doc.go +++ b/doc.go @@ -27,6 +27,10 @@ type Doc struct { flatten bool dropAnnots bool dropOutlines bool + // outline, when set, is written instead of carrying the sources' own + // bookmarks over: a document assembled rather than merged has an outline + // of its caller's rather than of anybody else's. + outline []Bookmark // How the file is written: packed into compressed object streams, and // protected or not. diff --git a/outlines.go b/outlines.go index e7b1aa0..30e774a 100644 --- a/outlines.go +++ b/outlines.go @@ -21,17 +21,43 @@ type outlineItem struct { // that loses every bookmark is a poor merge. func (d *Doc) DropOutlines() { d.dropOutlines = true } -// writeOutlines carries the bookmarks of every source document over, in the -// order the documents first contribute a page, dropping whatever pointed at a -// page that is no longer here. -func (d *Doc) writeOutlines(w *reader.Writer, where destinations) reader.Object { +// A Bookmark is an entry of an outline written from scratch: what it says, the +// page of this document it points at counting from one, and whatever sits +// under it. +// +// It is what a document assembled rather than merged carries — a shared +// edit, a report built out of pieces — where there is no source outline to +// carry over because the outline is the caller's own. +type Bookmark struct { + Title string + Page int + Children []Bookmark +} + +// SetOutline writes these bookmarks rather than carrying over the ones the +// sources had. An entry pointing at a page this document has not got is left +// out, and so is everything under it: a heading whose section has gone is not +// a heading any more. +// +// Passing nothing puts the sources' own bookmarks back. +func (d *Doc) SetOutline(marks []Bookmark) { d.outline = marks } + +// writeOutlines writes the bookmarks the caller set, or carries over those of +// every source document, in the order the documents first contribute a page, +// dropping whatever pointed at a page that is no longer here. +func (d *Doc) writeOutlines(w *reader.Writer, where destinations, refs []reader.Ref) reader.Object { if d.dropOutlines { return nil } var items []outlineItem - budget := maxOutlineItems - for _, src := range d.sources() { - items = append(items, d.readOutlines(w, src, where, &budget)...) + if len(d.outline) > 0 { + budget := maxOutlineItems + items = d.buildOutline(refs, d.outline, 0, &budget) + } else { + budget := maxOutlineItems + for _, src := range d.sources() { + items = append(items, d.readOutlines(w, src, where, &budget)...) + } } if len(items) == 0 { return nil @@ -62,6 +88,30 @@ func (d *Doc) sources() []*reader.Document { return out } +// buildOutline turns the caller's bookmarks into the ones a file carries, +// dropping any that point nowhere in this document. +func (d *Doc) buildOutline(refs []reader.Ref, marks []Bookmark, depth int, budget *int) []outlineItem { + if depth > maxOutlineDepth { + return nil + } + var out []outlineItem + for _, m := range marks { + if *budget <= 0 { + return out + } + if m.Page < 1 || m.Page > len(refs) { + continue + } + *budget-- + out = append(out, outlineItem{ + title: []byte(m.Title), + dest: reader.Array{refs[m.Page-1], reader.Name("Fit")}, + children: d.buildOutline(refs, m.Children, depth+1, budget), + }) + } + return out +} + // readOutlines reads one document's bookmarks. func (d *Doc) readOutlines(w *reader.Writer, src *reader.Document, where destinations, budget *int) []outlineItem { cat, _ := src.Catalog() diff --git a/outlines_test.go b/outlines_test.go index 4a4aa2d..1785b35 100644 --- a/outlines_test.go +++ b/outlines_test.go @@ -163,3 +163,86 @@ func TestBookmarksOfMadePagesAreNone(t *testing.T) { t.Errorf("bookmarks = %v", got) } } + +func TestAnOutlineWrittenFromScratch(t *testing.T) { + // A document assembled rather than merged has an outline of its caller's: + // there is no source outline to carry over. + d, err := Open(simple(t, 5)) + if err != nil { + t.Fatal(err) + } + d.SetOutline([]Bookmark{ + {Title: "One", Page: 1, Children: []Bookmark{ + {Title: "One and a half", Page: 2}, + }}, + {Title: "Three", Page: 3}, + {Title: "Nowhere", Page: 99}, // past the end + {Title: "Also nowhere", Page: 0}, // not a page + {Title: "Gone", Page: 42, Children: []Bookmark{{Title: "Under", Page: 1}}}, + }) + back, _ := writeAndOpen(t, d) + titles := outlineTitles(t, back) + want := []string{"One", "One and a half", "Three"} + if !reflect.DeepEqual(titles, want) { + t.Errorf("the outline says %v, want %v", titles, want) + } +} + +func TestAnOutlineSetAndThenTakenBack(t *testing.T) { + // Passing nothing puts the sources' own bookmarks back, and asking for + // none at all still wins. + f := richPDF(t, 3) + d, err := Open(f.bytes) + if err != nil { + t.Fatal(err) + } + d.SetOutline([]Bookmark{{Title: "Mine", Page: 1}}) + back, _ := writeAndOpen(t, d) + if titles := outlineTitles(t, back); len(titles) != 1 || titles[0] != "Mine" { + t.Errorf("the outline says %v", titles) + } + + d, _ = Open(f.bytes) + d.SetOutline(nil) + back, _ = writeAndOpen(t, d) + if titles := outlineTitles(t, back); len(titles) == 0 { + t.Error("the source's own bookmarks were not put back") + } + + d, _ = Open(f.bytes) + d.SetOutline([]Bookmark{{Title: "Mine", Page: 1}}) + d.DropOutlines() + back, _ = writeAndOpen(t, d) + if titles := outlineTitles(t, back); len(titles) != 0 { + t.Errorf("bookmarks were written after being dropped: %v", titles) + } +} + +func TestAnOutlineDeeperThanAnyoneNeeds(t *testing.T) { + // A tree deeper than the format is read to, and one wider than it is + // carried to, are both cut off rather than followed. + deep := Bookmark{Title: "top", Page: 1} + at := &deep + for i := 0; i < maxOutlineDepth+5; i++ { + at.Children = []Bookmark{{Title: "under", Page: 1}} + at = &at.Children[0] + } + d, err := Open(simple(t, 2)) + if err != nil { + t.Fatal(err) + } + d.SetOutline([]Bookmark{deep}) + if _, err := d.Bytes(); err != nil { + t.Fatal(err) + } + + wide := make([]Bookmark, maxOutlineItems+10) + for i := range wide { + wide[i] = Bookmark{Title: "one", Page: 1} + } + d, _ = Open(simple(t, 2)) + d.SetOutline(wide) + if _, err := d.Bytes(); err != nil { + t.Fatal(err) + } +} diff --git a/write.go b/write.go index 5c53579..62132a5 100644 --- a/write.go +++ b/write.go @@ -65,7 +65,7 @@ func (d *Doc) Bytes() ([]byte, error) { }) catalog := reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef} - if outlines := d.writeOutlines(w, where); outlines != nil { + if outlines := d.writeOutlines(w, where, refs); outlines != nil { catalog["Outlines"] = outlines } trailer := reader.Dict{"Root": w.Add(catalog)}