diff --git a/internal/book/chapters.go b/internal/book/chapters.go
index 3dd722a..14c1d16 100644
--- a/internal/book/chapters.go
+++ b/internal/book/chapters.go
@@ -2,6 +2,7 @@ package book
import (
"encoding/xml"
+ "os"
"path/filepath"
"sort"
"strings"
@@ -10,7 +11,8 @@ import (
// Chapter marks the first page of a chapter.
type Chapter struct {
Title string
- Page int
+ Page int // zero-based
+ Depth int // zero-based outline level
}
type comicInfo struct {
@@ -22,11 +24,25 @@ type comicInfo struct {
} `xml:"Pages"`
}
-// Chapters returns EPUB headings/document titles, ComicInfo.xml bookmarks,
-// or top-level archive folder markers. Metadata errors do not fall back to folders.
+// Chapters returns document outlines, headings, comic bookmarks or folder markers.
+// PDF and DJVU extraction runs once and stops when the book closes.
func (b *Book) Chapters() ([]Chapter, error) {
b.chapOnce.Do(func() {
- b.chaps, b.chapErr = b.comicInfoChapters()
+ b.mu.Lock()
+ if b.closed {
+ b.mu.Unlock()
+ b.chapErr = os.ErrClosed
+ return
+ }
+ ctx, arc := b.contextLocked(), b.arc
+ b.mu.Unlock()
+ if len(b.pages) > 0 {
+ if p, ok := b.pages[0].(toolPage); ok {
+ b.chaps, b.chapErr = p.chapters(ctx, b.Len())
+ return
+ }
+ }
+ b.chaps, b.chapErr = b.comicInfoChapters(arc)
if b.chaps == nil && b.chapErr == nil {
b.chaps = b.folderChapters()
}
@@ -34,11 +50,23 @@ func (b *Book) Chapters() ([]Chapter, error) {
return b.chaps, b.chapErr
}
-func (b *Book) comicInfoChapters() ([]Chapter, error) {
- if b.arc == nil {
+// CurrentChapter returns the nearest preceding destination, regardless of outline order.
+// Later entries win ties, so a subsection takes precedence over its parent.
+func CurrentChapter(chapters []Chapter, page int) int {
+ current := -1
+ for i, c := range chapters {
+ if c.Page <= page && (current < 0 || c.Page >= chapters[current].Page) {
+ current = i
+ }
+ }
+ return current
+}
+
+func (b *Book) comicInfoChapters(arc archive) ([]Chapter, error) {
+ if arc == nil {
return nil, nil
}
- for _, f := range b.arc.Entries() {
+ for _, f := range arc.Entries() {
if !strings.EqualFold(filepath.Base(f.Name()), "comicinfo.xml") {
continue
}
@@ -58,12 +86,12 @@ func (b *Book) comicInfoChapters() ([]Chapter, error) {
}
var chs []Chapter
for _, p := range ci.Pages.Page {
- if p.Bookmark == "" || p.Image < 0 || p.Image >= len(b.pages) {
+ if strings.TrimSpace(p.Bookmark) == "" || p.Image < 0 || p.Image >= len(b.pages) {
continue
}
chs = append(chs, Chapter{Title: p.Bookmark, Page: p.Image})
}
- sort.Slice(chs, func(i, j int) bool { return chs[i].Page < chs[j].Page })
+ sort.SliceStable(chs, func(i, j int) bool { return chs[i].Page < chs[j].Page })
return chs, nil
}
return nil, nil
diff --git a/internal/book/chapters_test.go b/internal/book/chapters_test.go
new file mode 100644
index 0000000..2cfd16d
--- /dev/null
+++ b/internal/book/chapters_test.go
@@ -0,0 +1,330 @@
+package book
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestPDFOutlineDestinations(t *testing.T) {
+ requireTools(t, "pdfinfo", "pdftohtml")
+ pdf := `%PDF-1.4
+1 0 obj<>>>>>endobj
+2 0 obj<>endobj
+3 0 obj<>endobj
+4 0 obj<>endobj
+5 0 obj<>endobj
+6 0 obj<>endobj
+7 0 obj</Parent 6 0 R/A<>>>endobj
+8 0 obj<>>>endobj
+trailer<>
+`
+ path := filepath.Join(t.TempDir(), "renamed.cbz")
+ if err := os.WriteFile(path, []byte(pdf), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ b, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer b.Close()
+ want := []Chapter{{"Part & one", 0, 0}, {"Café", 1, 1}}
+ var wg sync.WaitGroup
+ for range 4 {
+ wg.Go(func() {
+ if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, want) {
+ t.Errorf("outline = %#v, %v", got, err)
+ }
+ })
+ }
+ wg.Wait()
+ if len(b.cache) != 0 || len(b.encoded) != 0 {
+ t.Fatal("outline extraction rendered pages")
+ }
+ t.Setenv("PATH", t.TempDir())
+ if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, want) {
+ t.Fatalf("cached outline = %#v, %v", got, err)
+ }
+ cold := newBook(path, false)
+ cold.pages = []entry{toolPage{path: path, page: 1}}
+ defer cold.Close()
+ if _, err := cold.Chapters(); err == nil || !strings.Contains(err.Error(), "pdftohtml is not on PATH") {
+ t.Fatalf("missing outline tool: %v", err)
+ }
+}
+
+func TestPDFOutlineRejectsInvalidTargets(t *testing.T) {
+ data := []byte(`
`+strings.Repeat("word ", 800)+`Tail