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(`LastFirst & nestedZeroPast endBadExternal PartInside part`) + want := []Chapter{{"Last", 1, 0}, {"First & nested", 0, 1}, {"Part", 1, 0}, {"Inside part", 1, 1}} + if got, err := pdfOutline(data, 2); err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("outline = %#v, %v", got, err) + } + for _, data := range []string{``, ``} { + if _, err := pdfOutline([]byte(data), 2); err == nil { + t.Fatalf("accepted %q", data) + } + } +} + +func TestDJVUOutline(t *testing.T) { + names := djvuPageNames(" I 100 shared.djvu\n 1 P 100 cover.djvu\n 2 P 120 scan 02.djvu F=saved.djvu T=ii\n") + data := `(bookmarks ("Part \"one\"" "#1" ("Caf\303\251" "#scan%2002.djvu")) ("Group" "" ("Appendix" "?page=ii")) ("File" "#saved.djvu") ("Web" "https://example.com/#1") ("Missing" "#absent") ("Zero" "#0") ("Past end" "#3") ("Relative" "#+1"))` + want := []Chapter{{"Part \"one\"", 0, 0}, {"Café", 1, 1}, {"Group", 1, 0}, {"Appendix", 1, 1}, {"File", 1, 0}} + if got, err := djvuOutline([]byte(data), 2, names); err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("outline = %#v, %v", got, err) + } + for _, data := range []string{ + `(bookmarks ("Truncated" "#1")`, `(bookmarks (oops "#1"))`, `(bookmarks ("Bad\q" "#1"))`, `(bookmarks) extra`, + `(bookmarks ` + strings.Repeat(`("Nested" "#1" `, maxEPUBXMLDepth+1) + strings.Repeat(")", maxEPUBXMLDepth+2), + } { + if _, err := djvuOutline([]byte(data), 2, nil); err == nil { + t.Fatal("accepted malformed or unbounded outline") + } + } +} + +func TestDJVUOutlineDestinations(t *testing.T) { + requireTools(t, "c44", "djvm", "djvused") + dir := t.TempDir() + source := filepath.Join(dir, "page.ppm") + if err := os.WriteFile(source, []byte("P6\n64 32\n255\n"+strings.Repeat("\xff\xff\xff", 64*32)), 0o644); err != nil { + t.Fatal(err) + } + first, second := filepath.Join(dir, "first.djvu"), filepath.Join(dir, "second.djvu") + for _, page := range []string{first, second} { + if out, err := exec.Command("c44", source, page).CombinedOutput(); err != nil { + t.Fatalf("c44: %v: %s", err, out) + } + } + path := filepath.Join(dir, "book.djvu") + if out, err := exec.Command("djvm", "-c", path, first, second).CombinedOutput(); err != nil { + t.Fatalf("djvm: %v: %s", err, out) + } + script := filepath.Join(dir, "outline.dsed") + if err := os.WriteFile(script, []byte("set-outline\n(bookmarks (\"First\" \"#1\" (\"Caf\\303\\251\" \"#second.djvu\")))\n.\nsave\n"), 0o644); err != nil { + t.Fatal(err) + } + if out, err := exec.Command("djvused", "-f", script, path).CombinedOutput(); err != nil { + t.Fatalf("djvused: %v: %s", err, out) + } + b, err := Open(path) + if err != nil { + t.Fatal(err) + } + defer b.Close() + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, []Chapter{{"First", 0, 0}, {"Café", 1, 1}}) { + t.Fatalf("DJVU outline = %#v, %v", got, err) + } +} + +func TestEPUBNavigationSources(t *testing.T) { + const nav = `` + const ncx = `NCX titleNCX nested` + for _, source := range []string{"nav", "ncx", "both", "broken nav", "broken both", "invalid targets"} { + t.Run(source, func(t *testing.T) { + entries := epubFixture() + replaceEPUB(entries, "OPS/text/z.xhtml", "

", `

`) + replaceEPUB(entries, "OPS/text/z.xhtml", "", `

`+strings.Repeat("word ", 800)+`Tail

`) + navData, ncxData := nav, ncx + if strings.HasPrefix(source, "broken") { + navData = "" + } + if source == "broken both" { + ncxData = "" + } + if source == "invalid targets" { + navData = strings.ReplaceAll(nav, "text/", "missing/") + } + if source != "ncx" { + replaceEPUB(entries, "OPS/book.opf", "", ``) + entries = append(entries, memEntry{"OPS/nav.xhtml", []byte(navData)}) + } + if source != "nav" && source != "invalid targets" { + replaceEPUB(entries, "OPS/book.opf", "", ``) + replaceEPUB(entries, "OPS/book.opf", "", ``) + entries = append(entries, memEntry{"OPS/toc.ncx", []byte(ncxData)}) + } + b, err := Open(writeEPUB(t, entries, ".epub")) + if err != nil { + t.Fatal(err) + } + defer b.Close() + last := b.Len() - 1 + want := []Chapter{{"Part", 0, 0}, {"Navigation title", 0, 1}, {"Deep target", b.anchors["OPS/text/z.xhtml#deep"], 1}, {"Afterword", last, 0}} + switch source { + case "ncx", "broken nav": + want = []Chapter{{"NCX title", 0, 0}, {"NCX nested", last, 1}} + case "broken both", "invalid targets": + want = []Chapter{{"First heading", 0, 0}, {"Notes", last, 0}} + } + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("chapters = %#v, %v; want %#v", got, err, want) + } + if b.anchors["OPS/text/z.xhtml#deep"] <= 0 { + t.Fatal("fixture did not paginate the fragment target") + } + }) + } +} + +func TestEPUBImageSpineNavigation(t *testing.T) { + entries := epubFixture() + replaceEPUB(entries, "OPS/book.opf", `href="text/z.xhtml" media-type="application/xhtml+xml"`, `href="panel.png" media-type="image/png"`) + replaceEPUB(entries, "OPS/book.opf", "", ``) + entries = append(entries, memEntry{"OPS/panel.png", epubImage(t)}, memEntry{"OPS/nav.xhtml", []byte(``)}) + b, err := Open(writeEPUB(t, entries, ".epub")) + if err != nil { + t.Fatal(err) + } + defer b.Close() + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, []Chapter{{"Cover", 0, 0}}) { + t.Fatalf("image outline = %#v, %v", got, err) + } +} + +func TestEPUBSVGSpineNavigation(t *testing.T) { + entries := epubFixture() + replaceEPUB(entries, "OPS/book.opf", `href="text/z.xhtml" media-type="application/xhtml+xml"`, `href="wrapper.svg" media-type="image/svg+xml"`) + replaceEPUB(entries, "OPS/book.opf", "", ``) + entries = append(entries, + memEntry{"OPS/panel.png", epubImage(t)}, + memEntry{"OPS/wrapper.svg", []byte(``)}, + memEntry{"OPS/nav.xhtml", []byte(``)}, + ) + b, err := Open(writeEPUB(t, entries, ".epub")) + if err != nil { + t.Fatal(err) + } + defer b.Close() + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, []Chapter{{"Cover", 0, 0}}) { + t.Fatalf("SVG outline = %#v, %v", got, err) + } +} + +func TestDOCXOutlineLevels(t *testing.T) { + entries := docxFixture(t) + entries[1].data = []byte(` +Inherited +Level nine +Not a heading +Cyclic style +Built-in fallback +`) + entries[2].data = []byte(``) + b, err := Open(writeEPUB(t, entries, ".docx")) + if err != nil { + t.Fatal(err) + } + defer b.Close() + want := []Chapter{{"Inherited", 0, 0}, {"Level nine", 0, 8}, {"Built-in fallback", 0, 6}} + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("outline = %#v, %v", got, err) + } +} + +func TestDOCXInheritedOutlineOverridesStyleName(t *testing.T) { + for _, tc := range []struct { + level, direct string + want []Chapter + }{ + {"4", "", []Chapter{{"Heading", 0, 4}}}, + {"9", "", nil}, + {"4", ``, []Chapter{{"Heading", 0, 1}}}, + } { + entries := docxFixture(t) + entries[1].data = []byte(`` + tc.direct + `Heading`) + entries[2].data = []byte(``) + b, err := Open(writeEPUB(t, entries, ".docx")) + if err != nil { + t.Fatal(err) + } + defer b.Close() + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("inherited level %s, direct %q: %#v, %v", tc.level, tc.direct, got, err) + } + } +} + +func TestDOCAndFB2NestedChapters(t *testing.T) { + doc, err := parseXML([]byte(`Metadata titleMetadata titleExample AuthorFirstTextAfter breakNestedMore text`), maxDocumentTokens) + if err != nil { + t.Fatal(err) + } + docBook, err := layoutBook(newBook("book.doc", true), &epubPackage{}, &epubNode{name: xhtml("body"), children: []*epubNode{docbookNode(doc, 0)}}, nil) + if err != nil { + t.Fatal(err) + } + if got := docBook.PageText(0); !reflect.DeepEqual(got, []string{"First", "Text", "After break"}) { + t.Fatalf("DOC paragraph boundaries: %#v", got) + } + fb2, err := openFB2([]byte(`
<p>First</p>

Text

<p>Nested</p>

More text

`), "book.fb2") + if err != nil { + t.Fatal(err) + } + for _, b := range []*Book{docBook, fb2} { + defer b.Close() + want := []Chapter{{"First", 0, 0}, {"Nested", 1, 1}} + if got, err := b.Chapters(); err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("%s chapters = %#v, %v", b.Path, got, err) + } + } +} + +func TestComicChapterFallbacks(t *testing.T) { + pages := []entry{memEntry{name: "First/1.png"}, memEntry{name: "Last/2.png"}} + for _, tc := range []struct { + metadata string + want []Chapter + err bool + }{ + {"", []Chapter{{"First", 0, 0}, {"Last", 1, 0}}, false}, + {``, []Chapter{{"First", 0, 0}, {"Second", 1, 0}, {"Same page", 1, 0}}, false}, + {``, nil, true}, + } { + b := &Book{pages: pages} + if tc.metadata != "" { + b.arc = testArchive{memEntry{"ComicInfo.xml", []byte(tc.metadata)}} + } + got, err := b.Chapters() + b.Close() + if (err != nil) != tc.err || !reflect.DeepEqual(got, tc.want) { + t.Fatalf("chapters = %#v, %v", got, err) + } + } + chapters := []Chapter{{"Later", 5, 0}, {"Earlier", 1, 0}, {"Nested", 5, 1}, {"Appendix", 3, 0}} + for page, want := range map[int]int{0: -1, 1: 1, 4: 3, 5: 2, 99: 2} { + if got := CurrentChapter(chapters, page); got != want { + t.Fatalf("current at %d = %d, want %d", page, got, want) + } + } +} + +func TestDOCEmphasisRoles(t *testing.T) { + for role, want := range map[string]string{"": "em", "italic": "em", "bold": "strong", "underline": "span", "strikethrough": "span"} { + doc, err := parseXML([]byte(`Text`), maxDocumentTokens) + if err != nil { + t.Fatal(err) + } + if got := docbookNode(doc, 0); got.name.Local != want || got.allText() != "Text" { + t.Fatalf("emphasis %q = %s %q", role, got.name.Local, got.allText()) + } + } +} + +func TestLongHeadingTargetsItsFirstPage(t *testing.T) { + entries := epubFixture() + replaceEPUB(entries, "OPS/text/z.xhtml", "First heading", strings.Repeat("Long heading ", 500)) + b, err := Open(writeEPUB(t, entries, ".epub")) + if err != nil { + t.Fatal(err) + } + defer b.Close() + chapters, err := b.Chapters() + if err != nil || b.Len() < 3 || len(chapters) < 1 || chapters[0].Page != 0 { + t.Fatalf("long heading destination = %#v, %v", chapters, err) + } +} diff --git a/internal/book/doc.go b/internal/book/doc.go new file mode 100644 index 0000000..b86ec86 --- /dev/null +++ b/internal/book/doc.go @@ -0,0 +1,65 @@ +package book + +import ( + "encoding/xml" + "strconv" +) + +func docbookNode(n *epubNode, level int) *epubNode { + if n.name.Local == "" { + return &epubNode{text: n.text} + } + out := &epubNode{name: xhtml("span")} + if id := n.attr("id"); id != "" { + out.attrs = append(out.attrs, xml.Attr{Name: xml.Name{Local: "id"}, Value: id}) + } + switch n.name.Local { + case "bookinfo", "articleinfo", "info": + return &epubNode{} + case "book", "article", "chapter", "preface", "appendix", "section", "sect1", "sect2", "sect3", "sect4", "sect5": + out.name.Local = "div" + if n.name.Local != "book" && n.name.Local != "article" { + level++ + } + case "title": + out.name.Local = "h" + strconv.Itoa(max(1, min(level, 6))) + out.attrs = append(out.attrs, xml.Attr{Name: xml.Name{Local: "data-cbzr-depth"}, Value: strconv.Itoa(max(0, level-1))}) + case "para", "simpara": + out.name.Local = "p" + case "beginpage": + out.name.Local = "br" + case "literallayout", "programlisting": + out.name.Local = "pre" + case "emphasis": + switch n.attr("role") { + case "", "italic": + out.name.Local = "em" + case "bold": + out.name.Local = "strong" + } + case "itemizedlist": + out.name.Local = "ul" + case "orderedlist": + out.name.Local = "ol" + case "listitem": + out.name.Local = "li" + case "row": + out.name.Local = "tr" + case "entry": + out.name.Local = "td" + case "ulink", "link": + out.name.Local = "a" + href := n.attr("url") + if n.name.Local == "link" { + href = "#" + n.attr("linkend") + } + out.attrs = append(out.attrs, xml.Attr{Name: xml.Name{Local: "href"}, Value: href}) + } + for _, c := range n.children { + if (n.name.Local == "book" || n.name.Local == "article") && c.name.Local == "title" { + continue + } + out.children = append(out.children, docbookNode(c, level)) + } + return out +} diff --git a/internal/book/docx.go b/internal/book/docx.go index 17f9421..a5e12c8 100644 --- a/internal/book/docx.go +++ b/internal/book/docx.go @@ -14,9 +14,8 @@ var docxDrop = map[string]bool{"instrText": true, "delText": true, "del": true, var docxElements = map[string]string{"body": "body", "p": "p", "tbl": "div", "tr": "tr", "tc": "td", "br": "br", "cr": "br", "hyperlink": "a"} -// openDOCX lays word/document.xml out through the XHTML layout. Heading -// levels come from the style names, pictures and external links from the -// relationships. +// openDOCX lays word/document.xml out through the XHTML layout. Headings use +// paragraph outline levels and inherited styles; relationships supply images and links. func openDOCX(arc archive, path string) (*Book, error) { files := map[string]entry{} for _, e := range arc.Entries() { @@ -30,12 +29,12 @@ func openDOCX(arc archive, path string) (*Book, error) { if err != nil { return nil, fmt.Errorf("%s: %w", filepath.Base(path), err) } - styles := map[string]string{} // style id -> built-in name + styles := map[string]*epubNode{} if data, err := readEntry(files["word/styles.xml"], maxDocumentBytes); err == nil { if sheet, err := parseXML(data, maxDocumentTokens); err == nil { for _, s := range sheet.children { if s.name.Local == "style" { - styles[s.attr("styleId")] = strings.ToLower(s.child("name").attr("val")) + styles[s.attr("styleId")] = s } } } @@ -75,7 +74,7 @@ func openDOCX(arc archive, path string) (*Book, error) { } // docxNode converts one WordprocessingML element to XHTML. -func docxNode(n *epubNode, styles, images, links map[string]string, ancestors []*epubNode) *epubNode { +func docxNode(n *epubNode, styles map[string]*epubNode, images, links map[string]string, ancestors []*epubNode) *epubNode { if n.name.Local == "" { return &epubNode{text: n.text} } @@ -108,14 +107,10 @@ func docxNode(n *epubNode, styles, images, links map[string]string, ancestors [] return imageNode(name) case "p": props := n.child("pPr") - style := styles[props.child("pStyle").attr("val")] - level, heading := strings.CutPrefix(style, "heading ") - switch { - case style == "title": - out.name.Local = "h1" - case heading && len(level) == 1 && level[0] >= '1' && level[0] <= '6': - out.name.Local = "h" + level - case props.child("numPr").name.Local != "": + if level := docxHeading(props, styles); level > 0 { + out.name.Local = "h" + strconv.Itoa(min(level, 6)) + out.attrs = []xml.Attr{{Name: xml.Name{Local: "data-cbzr-depth"}, Value: strconv.Itoa(level - 1)}} + } else if props.child("numPr").name.Local != "" { out.name.Local = "li" } } @@ -136,6 +131,42 @@ func docxNode(n *epubNode, styles, images, links map[string]string, ancestors [] return out } +func docxHeading(props *epubNode, styles map[string]*epubNode) int { + id := props.child("pStyle").attr("val") + fallback := 0 + for range maxEPUBXMLDepth { + if outline := props.child("outlineLvl"); outline.name.Local != "" { + level, err := strconv.Atoi(outline.attr("val")) + if err == nil && level >= 0 && level < 9 { + return level + 1 + } + return 0 // Level 9 is body text. + } + style := styles[id] + name := id + if style != nil { + if value := style.child("name").attr("val"); value != "" { + name = value + } + } + name = strings.ReplaceAll(strings.ToLower(name), " ", "") + if fallback == 0 { + if name == "title" { + fallback = 1 + } + if level, ok := strings.CutPrefix(name, "heading"); ok && len(level) == 1 && level[0] >= '1' && level[0] <= '9' { + fallback = int(level[0] - '0') + } + } + if style == nil { + break + } + id = style.child("basedOn").attr("val") + props = style.child("pPr") + } + return fallback +} + // docxOn reports whether a run property such as w:b or w:i is switched on. func docxOn(n *epubNode) bool { switch n.attr("val") { diff --git a/internal/book/docx_test.go b/internal/book/docx_test.go index 3437d86..25aeaef 100644 --- a/internal/book/docx_test.go +++ b/internal/book/docx_test.go @@ -51,7 +51,7 @@ func TestOpenDOCX(t *testing.T) { t.Fatalf("last page: %#v", got) } chapters, err := b.Chapters() - if err != nil || !reflect.DeepEqual(chapters, []Chapter{{"Heading One", 0}}) { + if err != nil || !reflect.DeepEqual(chapters, []Chapter{{"Heading One", 0, 0}}) { t.Fatalf("chapters: %#v, %v", chapters, err) } bundled, err := epubBundledFonts() diff --git a/internal/book/epub.go b/internal/book/epub.go index 354858c..862b2f7 100644 --- a/internal/book/epub.go +++ b/internal/book/epub.go @@ -311,6 +311,7 @@ func openEPUB(arc archive, filename string) (*Book, error) { return nil, fmt.Errorf("EPUB invalid spine linear value") } first := len(b.pages) + layout.anchor(item.name, first) switch item.media { case "application/xhtml+xml", "image/svg+xml": doc, err := e.document(item.name, maxEPUBDocumentBytes) @@ -339,6 +340,7 @@ func openEPUB(arc archive, filename string) (*Book, error) { if !linear || len(b.pages) == 0 { return nil, fmt.Errorf("EPUB needs a nonempty spine with linear content") } + b.epubChapters(e, opf, manifest) // Keep the EPUB chapters indexed with the spine; skip comic metadata fallback. b.chapOnce.Do(func() {}) return b, nil diff --git a/internal/book/epub_chapters.go b/internal/book/epub_chapters.go new file mode 100644 index 0000000..5cc2d4f --- /dev/null +++ b/internal/book/epub_chapters.go @@ -0,0 +1,97 @@ +package book + +import ( + "fmt" + "slices" + "strings" +) + +// epubChapters prefers EPUB 3 navigation, then the EPUB 2 NCX, then layout headings. +func (b *Book) epubChapters(e *epubPackage, opf *epubNode, manifest map[string]epubItem) { + var sources []epubItem + for _, item := range opf.child("manifest").children { + if item.name.Local == "item" && slices.Contains(strings.Fields(item.attr("properties")), "nav") { + sources = append(sources, manifest[item.attr("id")]) + } + } + if ncx, ok := manifest[opf.child("spine").attr("toc")]; ok { + sources = append(sources, ncx) + } + var firstErr error + for _, source := range sources { + doc, err := e.document(source.name, maxMetadataBytes) + var root *epubNode + ncx := source.media == "application/x-dtbncx+xml" + if err == nil { + if ncx { + root = doc.child("navMap") + } else { + root = epubTOCNav(doc) + } + if root == nil || root.name.Local == "" { + err = fmt.Errorf("EPUB %s has no table of contents", source.name) + } + } + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + if chapters := b.epubOutline(root, source.name, 0, ncx); len(chapters) > 0 { + b.chaps = chapters + return + } + } + if len(b.chaps) == 0 { + b.chapErr = firstErr + } +} + +func epubTOCNav(n *epubNode) *epubNode { + if n.name.Local == "nav" { + for _, a := range n.attrs { + if a.Name.Space == "http://www.idpf.org/2007/ops" && a.Name.Local == "type" && slices.Contains(strings.Fields(a.Value), "toc") || a.Name.Local == "role" && slices.Contains(strings.Fields(a.Value), "doc-toc") { + return n + } + } + } + for _, c := range n.children { + if nav := epubTOCNav(c); nav != nil { + return nav + } + } + return nil +} + +func (b *Book) epubOutline(n *epubNode, name string, depth int, ncx bool) []Chapter { + var chapters []Chapter + for _, c := range n.children { + if c.name.Local == "ol" && !ncx { + chapters = append(chapters, b.epubOutline(c, name, depth, ncx)...) + continue + } + if ncx && c.name.Local != "navPoint" || !ncx && c.name.Local != "li" { + continue + } + label, href := c.child("a").allText(), c.child("a").attr("href") + if ncx { + label, href = c.child("navLabel").allText(), c.child("content").attr("src") + } else if label == "" { + label = c.child("span").allText() + } + children := b.epubOutline(c, name, depth+1, ncx) + page, found := 0, false + if target, internal, ok := epubLinkTarget(name, href); ok && internal { + // An unknown fragment must not jump to the document's first page. + page, found = b.anchors[target] + } else if href == "" && len(children) > 0 { + page, found = children[0].Page, true + } + if label != "" && found && page >= 0 && page < b.Len() { + chapters = append(chapters, Chapter{Title: label, Page: page, Depth: depth}) + } + chapters = append(chapters, children...) + } + return chapters +} diff --git a/internal/book/epub_layout.go b/internal/book/epub_layout.go index 2e80600..87a6652 100644 --- a/internal/book/epub_layout.go +++ b/internal/book/epub_layout.go @@ -25,6 +25,7 @@ type epubParagraph struct { links []epubLink italic, bold bool // every text node so far carried the style styled bool + chapter *Chapter // destination of the heading's first line } // style narrows the paragraph style to what all of its text shares. @@ -369,6 +370,9 @@ func (l *epubLayout) paragraph(p epubParagraph, heading bool) error { return err } } + if start == 0 && p.chapter != nil { + p.chapter.Page = len(l.book.pages) + } l.place(wrapped.NextLine, done) for i := range line.annotations { line.annotations[i].y += l.y diff --git a/internal/book/epub_render.go b/internal/book/epub_render.go index 62cbf6e..2ddde1e 100644 --- a/internal/book/epub_render.go +++ b/internal/book/epub_render.go @@ -9,6 +9,7 @@ import ( "image/png" "io" "math" + "strconv" "strings" "github.com/go-text/typesetting/font" @@ -349,18 +350,11 @@ func (l *epubLayout) document(e *epubPackage, name string, doc *epubNode, resour } links[i].start = 0 } - chapter := -1 if heading && pendingChapter >= 0 && strings.TrimSpace(paragraph.String()) != "" { - chapter, pendingChapter = pendingChapter, -1 - } - if err := l.paragraph(paragraph, heading); err != nil { - return err - } - if chapter >= 0 { - // Layout may have moved the heading to a fresh page. - l.book.chaps[chapter].Page = len(l.book.pages) + paragraph.chapter = &l.book.chaps[pendingChapter] + pendingChapter = -1 } - return nil + return l.paragraph(paragraph, heading) } var walk func(*epubNode) error walk = func(n *epubNode) error { @@ -419,8 +413,12 @@ func (l *epubLayout) document(e *epubPackage, name string, doc *epubNode, resour } } if title := n.allText(); title != "" { + depth := int(n.name.Local[1] - '1') + if value, err := strconv.Atoi(n.attr("data-cbzr-depth")); err == nil { + depth = max(0, min(value, maxEPUBXMLDepth)) + } pendingChapter = len(l.book.chaps) - l.book.chaps = append(l.book.chaps, Chapter{Title: title, Page: len(l.book.pages)}) + l.book.chaps = append(l.book.chaps, Chapter{Title: title, Page: len(l.book.pages), Depth: depth}) } } if n.name.Local == "li" { @@ -504,6 +502,9 @@ func (l *epubLayout) svg(e *epubPackage, name string, n *epubNode, resources map return fmt.Errorf("EPUB SVG transforms/styles are unsupported") } } + if id := n.attr("id"); id != "" { + l.pending = append(l.pending, epubAnchor{name + "#" + id, 0}) + } switch n.name.Local { case "svg", "g": for _, c := range n.children { diff --git a/internal/book/epub_test.go b/internal/book/epub_test.go index 47e64c8..455580e 100644 --- a/internal/book/epub_test.go +++ b/internal/book/epub_test.go @@ -94,7 +94,7 @@ func TestOpenEPUBTextSpineAndPages(t *testing.T) { if err != nil { t.Fatal(err) } - if !reflect.DeepEqual(chapters, []Chapter{{"First heading", 0}, {"Notes", 1}}) { + if !reflect.DeepEqual(chapters, []Chapter{{"First heading", 0, 0}, {"Notes", 1, 0}}) { t.Fatalf("chapters: %#v", chapters) } img, err := b.Page(0) @@ -493,7 +493,7 @@ func TestEPUBHeadingTargetsFirstTextPage(t *testing.T) { if chapters[0].Page != tc.page || !strings.HasPrefix(textEPUBPage(t, b, chapters[0].Page), "First heading") { t.Fatalf("first heading target: %#v", chapters[0]) } - if chapters[1] != (Chapter{"Next heading", b.Len() - 2}) || chapters[2] != (Chapter{"Notes", b.Len() - 1}) { + if chapters[1] != (Chapter{"Next heading", b.Len() - 2, 1}) || chapters[2] != (Chapter{"Notes", b.Len() - 1, 0}) { t.Fatalf("chapter target leaked: %#v", chapters) } imagePage := 0 @@ -527,7 +527,7 @@ func TestEPUBBodyStylesListsAndPre(t *testing.T) { t.Fatal("page text lookup") } chapters, err := b.Chapters() - if err != nil || !reflect.DeepEqual(chapters, []Chapter{{"First heading", 0}, {"Sub", 0}, {"Notes", 1}}) { + if err != nil || !reflect.DeepEqual(chapters, []Chapter{{"First heading", 0, 0}, {"Sub", 0, 2}, {"Notes", 1, 0}}) { t.Fatalf("chapters: %#v, %v", chapters, err) } bundled, err := epubBundledFonts() diff --git a/internal/book/fb2.go b/internal/book/fb2.go index e808d01..db776bb 100644 --- a/internal/book/fb2.go +++ b/internal/book/fb2.go @@ -120,10 +120,10 @@ func openFB2(data []byte, path string) (*Book, error) { images[n.attr("id")] = name } body := &epubNode{name: xhtml("body")} - body.children = append(body.children, fb2Node(info.child("coverpage"), "title-info", images, false)) + body.children = append(body.children, fb2Node(info.child("coverpage"), "title-info", images, false, 0)) for _, n := range doc.children { if n.name.Local == "body" { - body.children = append(body.children, fb2Node(n, "FictionBook", images, n.attr("name") != "")) + body.children = append(body.children, fb2Node(n, "FictionBook", images, n.attr("name") != "", 0)) } } return layoutBook(b, pkg, body, resources) @@ -132,11 +132,14 @@ func openFB2(data []byte, path string) (*Book, error) { // fb2Node converts one FictionBook element to XHTML. Section and body titles // become page-breaking headings; other titles and subtitles stay bold text. // Ids and link targets carry over. -func fb2Node(n *epubNode, parent string, images map[string]string, notes bool) *epubNode { +func fb2Node(n *epubNode, parent string, images map[string]string, notes bool, depth int) *epubNode { if n.name.Local == "" { return &epubNode{text: n.text} } out := &epubNode{name: xhtml("span")} + if n.name.Local == "section" { + depth++ + } for _, a := range n.attrs { if a.Name.Local == "id" || a.Name.Local == "href" && n.name.Local == "a" { out.attrs = append(out.attrs, a) @@ -156,17 +159,18 @@ func fb2Node(n *epubNode, parent string, images map[string]string, notes bool) * out.name.Local = "p" if n.name.Local == "title" && !notes && (parent == "section" || parent == "body") { out.name.Local = "h2" + out.attrs = append(out.attrs, xml.Attr{Name: xml.Name{Local: "data-cbzr-depth"}, Value: strconv.Itoa(max(0, depth-1))}) break } bold := &epubNode{name: xhtml("b")} for _, c := range n.children { - bold.children = append(bold.children, fb2Node(c, n.name.Local, images, notes)) + bold.children = append(bold.children, fb2Node(c, n.name.Local, images, notes, depth)) } out.children = []*epubNode{bold} return out } for _, c := range n.children { - out.children = append(out.children, fb2Node(c, n.name.Local, images, notes)) + out.children = append(out.children, fb2Node(c, n.name.Local, images, notes, depth)) } return out } diff --git a/internal/book/fb2_test.go b/internal/book/fb2_test.go index 8065f72..ac3e0f0 100644 --- a/internal/book/fb2_test.go +++ b/internal/book/fb2_test.go @@ -66,7 +66,7 @@ func checkFB2(t *testing.T, path, greeting string) { t.Fatalf("notes page: %#v", got) } chapters, err := b.Chapters() - if err != nil || !reflect.DeepEqual(chapters, []Chapter{{"Fiction Title", 1}, {"Chapter One", 2}}) { + if err != nil || !reflect.DeepEqual(chapters, []Chapter{{"Fiction Title", 1, 0}, {"Chapter One", 2, 0}}) { t.Fatalf("chapters: %#v, %v", chapters, err) } bundled, err := epubBundledFonts() diff --git a/internal/book/outline.go b/internal/book/outline.go new file mode 100644 index 0000000..b9acf9b --- /dev/null +++ b/internal/book/outline.go @@ -0,0 +1,177 @@ +package book + +import ( + "bytes" + "context" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "text/scanner" +) + +func (p toolPage) chapters(ctx context.Context, pages int) ([]Chapter, error) { + if !p.djvu { + // Poppler includes the complete outline even when only one page is converted. + out, err := runToolLimited(ctx, maxEPUBDocumentBytes, "pdftohtml", "-xml", "-i", "-q", "-enc", "UTF-8", "-f", "1", "-l", "1", "-stdout", p.path) + if err != nil { + return nil, err + } + return pdfOutline(out, pages) + } + out, err := runToolLimited(ctx, maxMetadataBytes, "djvused", "-u", "-e", "print-outline", p.path) + if err != nil || len(bytes.TrimSpace(out)) == 0 { + return nil, err + } + listing, err := runToolLimited(ctx, maxEPUBDocumentBytes, "djvused", "-u", "-e", "ls", p.path) + if err != nil { + return nil, err + } + return djvuOutline(out, pages, djvuPageNames(string(listing))) +} + +func pdfOutline(data []byte, pages int) ([]Chapter, error) { + doc, err := parseEPUBXML(data) + if err != nil { + return nil, err + } + if doc.name.Local != "pdf2xml" { + return nil, fmt.Errorf("pdftohtml returned no PDF metadata") + } + var walk func(*epubNode, int) []Chapter + walk = func(n *epubNode, depth int) []Chapter { + var chapters []Chapter + parent := -1 + for _, c := range n.children { + switch c.name.Local { + case "item": + parent = -1 + page, err := strconv.Atoi(c.attr("page")) + if title := c.allText(); title != "" && (c.attr("page") == "" || err == nil && page > 0 && page <= pages) { + parent = len(chapters) + chapters = append(chapters, Chapter{Title: title, Page: page - 1, Depth: depth}) + } + case "outline": + children := walk(c, depth+1) + if parent >= 0 && chapters[parent].Page < 0 && len(children) > 0 { + chapters[parent].Page = children[0].Page + } + chapters = append(chapters, children...) + } + } + valid := chapters[:0] + for _, c := range chapters { + if c.Page >= 0 { + valid = append(valid, c) + } + } + return valid + } + return walk(doc.child("outline"), 0), nil +} + +var djvuPageLine = regexp.MustCompile(`(?m)^\s*(\d+) P\s+\d+ (.*)$`) + +func djvuPageNames(listing string) map[string]int { + names := map[string]int{} + for _, match := range djvuPageLine.FindAllStringSubmatch(listing, -1) { + page, err := strconv.Atoi(match[1]) + if err != nil || page < 1 { + continue + } + id, title, _ := strings.Cut(strings.TrimSuffix(match[2], "\r"), " T=") + id, saved, _ := strings.Cut(id, " F=") + for _, name := range []string{id, saved, title} { + if name != "" { + names[name] = page - 1 + } + } + } + return names +} + +func djvuOutline(data []byte, pages int, names map[string]int) ([]Chapter, error) { + var s scanner.Scanner + s.Init(bytes.NewReader(bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")))) + s.Mode = scanner.ScanIdents | scanner.ScanStrings + var parseErr error + s.Error = func(_ *scanner.Scanner, message string) { parseErr = fmt.Errorf("DJVU outline: %s", message) } + readString := func() string { + if s.Scan() != scanner.String { + parseErr = fmt.Errorf("DJVU outline needs a quoted title and destination") + return "" + } + value, err := strconv.Unquote(s.TokenText()) + if err != nil { + parseErr = fmt.Errorf("DJVU outline: %w", err) + } + return value + } + if s.Scan() != '(' || s.Scan() != scanner.Ident || s.TokenText() != "bookmarks" { + return nil, fmt.Errorf("DJVU outline lacks bookmarks") + } + count := 0 + var walk func(int) []Chapter + walk = func(depth int) []Chapter { + var chapters []Chapter + for parseErr == nil { + token := s.Scan() + if token == ')' { + return chapters + } + count++ + if token != '(' || depth >= maxEPUBXMLDepth || count > maxEPUBEntries { + parseErr = fmt.Errorf("DJVU outline is malformed or exceeds its size limit") + break + } + title, ref := readString(), readString() + page := djvuDestination(ref, names) + children := walk(depth + 1) + if ref == "" && len(children) > 0 { + page = children[0].Page + } + if title = strings.Join(strings.Fields(title), " "); title != "" && page >= 0 && page < pages { + chapters = append(chapters, Chapter{Title: title, Page: page, Depth: depth}) + } + chapters = append(chapters, children...) + } + return chapters + } + chapters := walk(0) + if parseErr != nil { + return nil, parseErr + } + if s.Scan() != scanner.EOF { + return nil, fmt.Errorf("DJVU outline has trailing data") + } + return chapters, parseErr +} + +func djvuDestination(ref string, names map[string]int) int { + var name string + var err error + switch { + case strings.HasPrefix(ref, "#"): + name, err = url.PathUnescape(ref[1:]) + case strings.HasPrefix(ref, "?"): + var query url.Values + query, err = url.ParseQuery(ref[1:]) + name = query.Get("page") + default: + return -1 + } + if err != nil || name == "" || strings.HasPrefix(name, "+") || strings.HasPrefix(name, "-") { + return -1 + } + if page, err := strconv.Atoi(name); err == nil { + if page > 0 { + return page - 1 + } + return -1 + } + if page, ok := names[name]; ok { + return page + } + return -1 +} diff --git a/internal/book/tool.go b/internal/book/tool.go index 336313e..239810b 100644 --- a/internal/book/tool.go +++ b/internal/book/tool.go @@ -39,6 +39,10 @@ func runTool(name string, args ...string) ([]byte, error) { } func runToolContext(ctx context.Context, name string, args ...string) ([]byte, error) { + return runToolLimited(ctx, maxPageBytes, name, args...) +} + +func runToolLimited(ctx context.Context, limit int64, name string, args ...string) ([]byte, error) { ctx, cancel := context.WithTimeout(ctx, toolTimeout) defer cancel() cmd := exec.CommandContext(ctx, name, args...) @@ -54,7 +58,7 @@ func runToolContext(ctx context.Context, name string, args ...string) ([]byte, e } return nil, err } - out, readErr := readBounded(stdout, maxPageBytes) + out, readErr := readBounded(stdout, limit) if readErr != nil { cancel() // stop a writer that outgrew the limit before waiting for it } @@ -162,15 +166,16 @@ func ppmToPNG(data []byte) ([]byte, error) { return out.Bytes(), nil } -// openDOC lays the paragraphs antiword extracts out as text pages. +// openDOC keeps the headings in antiword's DocBook output. func openDOC(path string) (*Book, error) { - out, err := runTool("antiword", "-w", "0", "-m", "UTF-8.txt", path) + out, err := runTool("antiword", "-x", "db", path) if err != nil { return nil, fmt.Errorf("%s: %w", filepath.Base(path), err) } - body := &epubNode{name: xhtml("body")} - for _, line := range strings.Split(string(out), "\n") { - body.children = append(body.children, &epubNode{name: xhtml("p"), children: []*epubNode{{text: line}}}) + doc, err := parseXML(out, maxDocumentTokens) + if err != nil { + return nil, fmt.Errorf("%s: %w", filepath.Base(path), err) } + body := &epubNode{name: xhtml("body"), children: []*epubNode{docbookNode(doc, 0)}} return layoutBook(newBook(path, true), &epubPackage{}, body, nil) } diff --git a/internal/book/tool_test.go b/internal/book/tool_test.go index 76e4cfa..4e6e9d2 100644 --- a/internal/book/tool_test.go +++ b/internal/book/tool_test.go @@ -65,7 +65,7 @@ func TestOpenPDF(t *testing.T) { } } }) - requireTools(t, "pdfinfo", "pdftoppm") + requireTools(t, "pdfinfo", "pdftoppm", "pdftohtml") for _, name := range []string{shifted, xmlShifted} { b, err := Open(name) if err != nil { diff --git a/internal/ui/chapters.go b/internal/ui/chapters.go new file mode 100644 index 0000000..cf95621 --- /dev/null +++ b/internal/ui/chapters.go @@ -0,0 +1,50 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" + + "cbzr/internal/book" +) + +type chaptersMsg struct { + target *pane + book *book.Book + chapters []book.Chapter + err error +} + +func (m Model) loadChapters(p *pane) tea.Cmd { + if p.book == nil || p.chaptersReady || p.chaptersLoading { + return nil + } + p.chaptersLoading = true + b := p.book + return func() tea.Msg { + chapters, err := b.Chapters() + return chaptersMsg{target: p, book: b, chapters: chapters, err: err} + } +} + +func (m Model) updateChapters(msg chaptersMsg) (tea.Model, tea.Cmd) { + for _, p := range m.panes[:m.paneCount()] { + if p != msg.target || p.book != msg.book { + continue + } + p.chapters, p.chapterErr = msg.chapters, msg.err + p.chaptersReady, p.chaptersLoading = true, false + if p == m.panes[m.active] && m.menu.kind == menuChapters && (m.mode == modeMenu || m.mode == modeMenuFilter) { + filter, mode := m.menu.filter, m.mode + updated, cmd := m.openChapters() + m = updated.(Model) + if m.mode == modeMenu { + m.menu.filter = filter + m.menu.applyFilter() + m.menu.move(0, m.menuHeight()) + m.mode = mode + } + return m, cmd + } + break + } + return m, nil +} diff --git a/internal/ui/chapters_test.go b/internal/ui/chapters_test.go new file mode 100644 index 0000000..c458b65 --- /dev/null +++ b/internal/ui/chapters_test.go @@ -0,0 +1,119 @@ +package ui + +import ( + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "cbzr/internal/book" +) + +func TestChapterMenuLoadsWithoutBlocking(t *testing.T) { + b, err := book.Open(testBookPath(t, ``)) + if err != nil { + t.Fatal(err) + } + defer b.Close() + m := testModel() + p := m.panes[0] + p.book = b + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = updated.(Model) + if cmd == nil || p.chaptersReady || !p.chaptersLoading || !strings.Contains(m.View(), "loading chapters") { + t.Fatal("Tab did not defer loading the chapters") + } + if m.loadChapters(p) != nil { + t.Fatal("a second request started another extraction") + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + m = updated.(Model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("Last")}) + m = updated.(Model) + updated, _ = m.Update(cmd()) + m = updated.(Model) + if !p.chaptersReady || p.chaptersLoading || m.mode != modeMenuFilter || m.menu.filter != "Last" || len(m.menu.items) != 1 || m.menu.items[0].page != 2 { + t.Fatalf("loaded menu = %#v", m.menu) + } + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + updated, _ = updated.(Model).Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(Model) + if p.page != 2 || m.mode != modeRead || !strings.Contains(m.paneLines(0)[0], "[Last]") { + t.Fatal("chapter selection did not update the page and header") + } + if _, cmd := m.openChapters(); cmd != nil { + t.Fatal("reopening a cached menu started work") + } +} + +func TestChapterResultsIgnoreClosedMenusAndReplacedBooks(t *testing.T) { + for _, action := range []string{"close menu", "replace book", "remap pane"} { + t.Run(action, func(t *testing.T) { + b, err := book.Open(testBookPath(t)) + if err != nil { + t.Fatal(err) + } + defer b.Close() + m := testModel() + p := m.panes[0] + p.book = b + updated, _ := m.openChapters() + m = updated.(Model) + msg := chaptersMsg{target: p, book: b, chapters: []book.Chapter{{Title: "Loaded", Page: 1}}} + switch action { + case "close menu": + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = updated.(Model) + case "replace book": + p.book = &book.Book{} + case "remap pane": + m.panes[0] = newPane() + } + updated, _ = m.Update(msg) + m = updated.(Model) + if action == "close menu" { + if m.mode != modeRead || !p.chaptersReady { + t.Fatal("late chapters reopened a closed menu or lost the cache") + } + } else if m.panes[0].chaptersReady || len(m.menu.items) != 0 { + t.Fatal("stale chapters reached another book") + } + }) + } +} + +func TestChapterMenuPreservesOutlineOrderAndDepth(t *testing.T) { + m := testModel() + p := m.panes[0] + p.book = &book.Book{Title: "Outline"} + p.page, p.chaptersReady = 1, true + p.chapters = []book.Chapter{{Title: "Later", Page: 2}, {Title: "Earlier", Page: 0}, {Title: "Nested\x1b[31m title", Page: 1, Depth: 2}, {Title: "Appendix", Page: 0}} + updated, _ := m.openChapters() + m = updated.(Model) + if m.menu.cursor != 2 || m.menu.items[0].page != 2 || !strings.HasPrefix(m.menu.items[2].label, " Nested title") { + t.Fatalf("outline menu = %#v", m.menu) + } + if !strings.Contains(m.paneLines(0)[0], "[Nested title]") { + t.Fatal("header assumed the outline was sorted by page") + } +} + +func TestChapterLoadingErrorsAndEmptyOutlines(t *testing.T) { + for _, err := range []error{nil, errors.New("pdftohtml is not on PATH")} { + m := testModel() + p := m.panes[0] + p.book = &book.Book{} + m.zen = true + updated, _ := m.openChapters() + m = updated.(Model) + updated, _ = m.Update(chaptersMsg{target: p, book: p.book, err: err}) + m = updated.(Model) + if m.mode != modeRead || m.zen || m.status == "" { + t.Fatal("empty or failed outline left an empty loading menu") + } + if err != nil && !strings.Contains(m.status, err.Error()) { + t.Fatalf("error = %q", m.status) + } + } +} diff --git a/internal/ui/menu.go b/internal/ui/menu.go index 6100955..607fbde 100644 --- a/internal/ui/menu.go +++ b/internal/ui/menu.go @@ -25,13 +25,14 @@ type menuItem struct { // menu is a modal list with vim navigation and fuzzy filtering. type menu struct { - kind menuKind - title string - all []menuItem // unfiltered - items []menuItem // filtered view - filter string - cursor int - top int + kind menuKind + title string + all []menuItem // unfiltered + items []menuItem // filtered view + filter string + cursor int + top int + loading bool } // fuzzyScore matches query as a case-insensitive subsequence of s. @@ -110,7 +111,9 @@ func (mn *menu) view(width, height int, footer string) string { } sb.WriteString(titleActive.Render(head)) sb.WriteString("\n") - if len(mn.items) == 0 { + if mn.loading { + sb.WriteString(dim.Render(" loading chapters…")) + } else if len(mn.items) == 0 { sb.WriteString(dim.Render(" (no matches)")) } end := min(len(mn.items), mn.top+rows) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 1e650f1..10320f5 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -56,6 +56,11 @@ type pane struct { loading bool gen int + chapters []book.Chapter + chapterErr error + chaptersReady bool + chaptersLoading bool + rot int // quarter turns cw inverted bool zoom float64 @@ -285,7 +290,7 @@ func (m Model) rememberPane(i int) { } func (m Model) Init() tea.Cmd { - return tea.SetWindowTitle("cbzr") + return tea.Batch(tea.SetWindowTitle("cbzr"), m.loadChapters(m.panes[0]), m.loadChapters(m.panes[1])) } // ---- layout ---------------------------------------------------------------- @@ -447,6 +452,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ocrMsg: return m.updateOCR(msg) + case chaptersMsg: + return m.updateChapters(msg) + case tea.MouseMsg: return m.updateMouse(msg) @@ -626,6 +634,7 @@ func (m Model) updateRead(msg tea.KeyMsg) (tea.Model, tea.Cmd) { for _, p := range m.panes { p.gen++ p.loading = false + p.chaptersLoading = false } m.nativeRequested = true return m, tea.Quit @@ -1121,7 +1130,7 @@ func (m Model) openBook(i int, path string, prev tea.Cmd) (tea.Model, tea.Cmd) { } } m.syncServer() - return m, tea.Batch(prev, m.renderPane(i)) + return m, tea.Batch(prev, m.renderPane(i), m.loadChapters(p)) } func (m Model) toggleSplit() (tea.Model, tea.Cmd) { @@ -1209,27 +1218,33 @@ func (m Model) openChapters() (tea.Model, tea.Cmd) { if p.book == nil { return m, nil } - chs, err := p.book.Chapters() - if err != nil { - m.zen = false - m.status = "chapters: " + err.Error() - return m, m.rerenderAll() + if !p.chaptersReady { + m.menu = menu{kind: menuChapters, title: "Chapters — " + safeText(p.book.Title), loading: true} + m.mode = modeMenu + return m, m.loadChapters(p) } - if len(chs) == 0 { + if p.chapterErr != nil || len(p.chapters) == 0 { + m.mode = modeRead m.status = "no chapter info in this book" + if p.chapterErr != nil { + m.status = "chapters: " + p.chapterErr.Error() + } + if m.zen { + m.zen = false + return m, m.rerenderAll() + } return m, nil } - items := make([]menuItem, len(chs)) - cursor := 0 - for i, c := range chs { + items := make([]menuItem, len(p.chapters)) + for i, c := range p.chapters { + label := strings.Repeat(" ", max(0, min(c.Depth, 12))) + safeText(c.Title) + label = ansi.Truncate(label, 40, "…") items[i] = menuItem{ - label: fmt.Sprintf("%-40s p.%d", ansi.Truncate(safeText(c.Title), 40, "…"), c.Page+1), + label: label + strings.Repeat(" ", 40-ansi.StringWidth(label)) + fmt.Sprintf(" p.%d", c.Page+1), page: c.Page, } - if c.Page <= p.page { - cursor = i - } } + cursor := max(0, book.CurrentChapter(p.chapters, p.page)) m.menu = menu{kind: menuChapters, title: "Chapters — " + safeText(p.book.Title), all: items, cursor: cursor} m.menu.applyFilter() m.menu.move(0, m.menuHeight()) @@ -1713,14 +1728,9 @@ func (m Model) paneLines(i int) []string { title = "🔖 " + title } var mods []string - if chapters, err := p.book.Chapters(); err == nil { - for j := len(chapters) - 1; j >= 0; j-- { - if chapters[j].Page <= p.page { - if name := strings.TrimSpace(safeText(chapters[j].Title)); name != "" { - mods = append(mods, name) - } - break - } + if current := book.CurrentChapter(p.chapters, p.page); current >= 0 { + if name := strings.TrimSpace(safeText(p.chapters[current].Title)); name != "" { + mods = append(mods, name) } } if p.rot != 0 { diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index b7670ab..8289f62 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -61,6 +61,8 @@ func TestPaneHeaderShowsCurrentChapter(t *testing.T) { m.width, m.spread = 200, tc.spread p := m.panes[0] p.book, p.page = b, tc.page + updated, _ := m.Update(m.loadChapters(p)()) + m = updated.(Model) for _, inverted := range []bool{false, true} { p.inverted = inverted header := m.paneLines(0)[0] @@ -533,6 +535,7 @@ func TestZenErrorsScheduleReplacementFrame(t *testing.T) { updated, cmd = m.updateMenuRename(tea.KeyMsg{Type: tea.KeyEnter}) case "chapters": updated, cmd = m.openChapters() + updated, cmd = updated.(Model).Update(cmd()) } m = updated.(Model) if m.zen || m.mode != modeRead || m.status == "" || cmd == nil || !p.loading {