From 2c6e81a2c83bbcca9daf07b4982502fa0fd5db3d Mon Sep 17 00:00:00 2001 From: "T.v.Dein" Date: Sun, 19 Oct 2025 20:39:04 +0200 Subject: [PATCH] add cover image support (#7) --- cmd/config.go | 5 +++- cmd/pager.go | 63 +++++++++++++++++++++++++++++++++++------------- cmd/view.go | 16 +++++++++--- go.mod | 7 ++++++ go.sum | 19 +++++++++++++++ pkg/epub/book.go | 15 +++++++----- pkg/epub/open.go | 18 ++++++++++---- 7 files changed, 110 insertions(+), 33 deletions(-) diff --git a/cmd/config.go b/cmd/config.go index b180a07..31eda4b 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -32,7 +32,7 @@ import ( ) const ( - Version string = `v0.0.4` + Version string = `v0.0.5` Usage string = `This is epuppy, a terminal ui ebook viewer. Usage: epuppy [options] @@ -42,6 +42,7 @@ Options: -s --store-progress remember reading position -n --line-numbers add line numbers -c --config use config +-i --cover-image display cover image -t --txt dump readable content to STDOUT -x --xml dump source xml to STDOUT -N --no-color disable colors (or use $NO_COLOR env var) @@ -63,6 +64,7 @@ type Config struct { ColorDark ColorSetting `koanf:"colordark"` // comes from config file only ColorLight ColorSetting `koanf:"colorlight"` // comes from config file only ShowHelp bool `koanf:"help"` + ShowCover bool `koanf:"cover-image"` // -i Colors Colors // generated from user config file or internal defaults, respects dark mode Document string InitialProgress int // lines @@ -89,6 +91,7 @@ func InitConfig(output io.Writer) (*Config, error) { flagset.BoolP("txt", "t", false, "dump readable content to STDOUT") flagset.BoolP("xml", "x", false, "dump xml to STDOUT") flagset.BoolP("no-color", "N", false, "disable colors") + flagset.BoolP("cover-image", "i", false, "show cover image") flagset.BoolP("help", "h", false, "show help") flagset.StringP("config", "c", "", "read config from file") diff --git a/cmd/pager.go b/cmd/pager.go index bd3a7fd..91a10f6 100644 --- a/cmd/pager.go +++ b/cmd/pager.go @@ -21,10 +21,13 @@ package cmd // https://github.com/charmbracelet/bubbletea/tree/main/examples/pager import ( + "bytes" "fmt" + "image" "os" "strings" + "github.com/blacktop/go-termimg" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/viewport" @@ -132,6 +135,7 @@ type Doc struct { hideUi bool meta *Meta config *Config + Cover *termimg.ImageWidget keys keyMap help help.Model @@ -220,7 +224,12 @@ func (m *Doc) Rewrap() { // wrapping has changed, update viewport and line count if content != "" { - m.viewport.SetContent(content) + if m.Cover != nil { + cover, _ := m.Cover.Render() + m.viewport.SetContent(cover + "\n\n" + content) + } else { + m.viewport.SetContent(content) + } m.meta.lines = len(strings.Split(content, "\n")) } @@ -270,7 +279,15 @@ func max(a, b int) int { return b } -func Pager(conf *Config, title, message string) (int, error) { +type Ebook struct { + Config *Config + Title string + Body string + Cover []byte + MediaType string +} + +func Pager(book *Ebook) (int, error) { width := 80 scrollto := 0 @@ -281,32 +298,44 @@ func Pager(conf *Config, title, message string) (int, error) { } } - if conf.StoreProgress { - scrollto = conf.InitialProgress + if book.Config.StoreProgress { + scrollto = book.Config.InitialProgress } - if conf.LineNumbers { + if book.Config.LineNumbers { catn := "" - for idx, line := range strings.Split(message, "\n") { + for idx, line := range strings.Split(book.Body, "\n") { catn += fmt.Sprintf("%4d: %s\n", idx, line) } - message = catn + book.Body = catn } meta := Meta{ initialprogress: scrollto, - lines: len(strings.Split(message, "\n")), + lines: len(strings.Split(book.Body, "\n")), + } + + doc := Doc{ + content: book.Body, + title: book.Title, + initialwidth: width, + meta: &meta, + config: book.Config, + keys: keys, + } + + if book.MediaType != "" && book.Config.ShowCover { + img, _, err := image.Decode(bytes.NewReader(book.Cover)) + if err != nil { + return 0, fmt.Errorf("failed to load cover image: %w", err) + } + + doc.Cover = termimg.NewImageWidgetFromImage(img) + doc.Cover.SetSize(width, width).SetProtocol(termimg.Auto) } p := tea.NewProgram( - Doc{ - content: message, - title: title, - initialwidth: width, - meta: &meta, - config: conf, - keys: keys, - }, + doc, tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel ) @@ -315,7 +344,7 @@ func Pager(conf *Config, title, message string) (int, error) { return 0, fmt.Errorf("could not run pager: %w", err) } - if conf.Debug { + if book.Config.Debug { fmt.Printf("scrollto: %d, last: %d, diff: %d\n", scrollto, meta.currentline, scrollto-meta.currentline) } diff --git a/cmd/view.go b/cmd/view.go index 28a5fbb..c70a525 100644 --- a/cmd/view.go +++ b/cmd/view.go @@ -44,7 +44,11 @@ func ViewText(conf *Config) (int, error) { return fmt.Println(string(data)) } - return Pager(conf, conf.Document, string(data)) + return Pager(&Ebook{ + Config: conf, + Title: conf.Document, + Body: string(data), + }) } func ViewEpub(conf *Config) (int, error) { @@ -74,11 +78,15 @@ func ViewEpub(conf *Config) (int, error) { return fmt.Println(buf.String()) } - return Pager(conf, head.String(), buf.String()) + return Pager(&Ebook{ + Config: conf, + Title: head.String(), + Body: buf.String(), + Cover: book.CoverImage, + MediaType: book.CoverMediaType, + }) } -// FIXME: since the switch to book.Files() in epub.Open() this -// returns invalid chapter numbering func fetchByContent(conf *Config, buf *strings.Builder, book *epub.Book) bool { chapter := 1 var gotbody bool diff --git a/go.mod b/go.mod index e8cfbf0..f0d7e52 100644 --- a/go.mod +++ b/go.mod @@ -41,14 +41,21 @@ require ( require ( github.com/antchfx/xpath v1.3.5 // indirect + github.com/blacktop/go-termimg v0.1.20 // indirect + github.com/charmbracelet/x/mosaic v0.0.0-20250702191427-5bdfc8f2e4ff // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/makeworld-the-better-one/dither/v2 v2.4.0 // indirect + github.com/mattn/go-sixel v0.0.5 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/soniakeys/quant v1.0.0 // indirect + golang.org/x/image v0.25.0 // indirect golang.org/x/net v0.44.0 // indirect golang.org/x/tools v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 6eeef7d..ea5f985 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/blacktop/go-termimg v0.1.20 h1:+EAUc3c9hwE/fUYaqRV1BSLvAlOuLySgLTEBzxGbYK4= +github.com/blacktop/go-termimg v0.1.20/go.mod h1:nwxrOjfFcBjtS358oIGBLfscSLnCpNdRlMVRxsnZwMU= github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -22,8 +24,11 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/mosaic v0.0.0-20250702191427-5bdfc8f2e4ff h1:OVBKPzoa0k5ZVMoor27BReRZxER1IEDtLHXkRjaHElg= +github.com/charmbracelet/x/mosaic v0.0.0-20250702191427-5bdfc8f2e4ff/go.mod h1:5qLP4S++M5quSc/xbvWWW8vKkgKwOqOT/IVhAas26XI= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -47,6 +52,8 @@ github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE= +github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -54,6 +61,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8= +github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -66,6 +75,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -76,8 +87,12 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= +github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -91,6 +106,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -161,5 +178,7 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/epub/book.go b/pkg/epub/book.go index d476c58..34b90c6 100644 --- a/pkg/epub/book.go +++ b/pkg/epub/book.go @@ -11,12 +11,15 @@ import ( // Book epub book type Book struct { - Ncx Ncx `json:"ncx"` - Opf Opf `json:"opf"` - Container Container `json:"-"` - Mimetype string `json:"-"` - Content []Content - fd *zip.ReadCloser + Ncx Ncx `json:"ncx"` + Opf Opf `json:"opf"` + Container Container `json:"-"` + Mimetype string `json:"-"` + Content []Content + fd *zip.ReadCloser + CoverImage []byte + CoverFile string + CoverMediaType string } // Open open resource file diff --git a/pkg/epub/open.go b/pkg/epub/open.go index 1157b2d..a42d2f6 100644 --- a/pkg/epub/open.go +++ b/pkg/epub/open.go @@ -45,8 +45,11 @@ func Open(fn string, dumpxml bool) (*Book, error) { if err != nil { return &bk, err } + } - break + if mf.ID == "cover-image" { + bk.CoverFile = mf.Href + bk.CoverMediaType = mf.MediaType } } @@ -61,13 +64,18 @@ func Open(fn string, dumpxml bool) (*Book, error) { if err := ct.String(content); err != nil { return &bk, err } + + bk.Content = append(bk.Content, ct) + + if dumpxml { + fmt.Println(string(ct.XML)) + } } - bk.Content = append(bk.Content, ct) - - if dumpxml { - fmt.Println(string(ct.XML)) + if strings.Contains(file, bk.CoverFile) { + bk.CoverImage = content } + } if dumpxml {