From 3f765daebb0b148204b6feda5c7e6b4af393bcb1 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 25 Aug 2026 22:27:11 +0200 Subject: [PATCH] Write an outline of one's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document assembled rather than merged has an outline of its caller's: there is no source outline to carry over, because the pages came from several places and the headings over them are new. Doc.SetOutline writes the bookmarks given rather than the sources' own. 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' bookmarks back, and DropOutlines still wins over both. This is what a shared edit needs: several people agree on an order of pages drawn from several files, and the headings over them belong to the plan rather than to any of the files. --- doc.go | 4 +++ outlines.go | 64 +++++++++++++++++++++++++++++++++---- outlines_test.go | 83 ++++++++++++++++++++++++++++++++++++++++++++++++ write.go | 2 +- 4 files changed, 145 insertions(+), 8 deletions(-) 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)}