12 Commits

Author SHA1 Message Date
T.v.Dein
238972f11f Parserfixes std (#11)
* clean svg and cdata
* refactored ebook preparation, separated from calling the pager
* added better unit tests
* add free ebooks for testing
2025-10-21 21:57:12 +02:00
T.v.Dein
f524083210 Fix more parser failures (#10)
* stabilize section parsing, now seems to read all ebooks I tested with
* refactored Open() into smaller funcs
* bump version
2025-10-20 18:54:49 +00:00
T.v.Dein
cb671b7401 Fix crash and add support for content in <div> (#9)
* fix #8: better regex to remove html entities
* add opf and ncx debug
* make epub content retrieval more flexible
* fix epub content retrieval: also support html files with <div>
2025-10-19 22:30:13 +02:00
T.v.Dein
2c6e81a2c8 add cover image support (#7) 2025-10-19 20:39:04 +02:00
c2abc4ba4d gofmt 2025-10-17 14:22:03 +02:00
b9b0ad8603 add badges 2025-10-17 14:19:16 +02:00
T.v.Dein
08f470e0d5 Ui features (#6)
* fix #3: added h ui command to toggle ui
* fix #4: added -N flag to disable colors
* fixed styling of line numbers (-n)
* fix #5 add usage section
* add pkg/epub module readme
* add license terms to all file headers
* fixed usage printing
* added basic unit tests
* run the tests only on linux due to the use of base64 utitlity
2025-10-17 14:10:45 +02:00
T.v.Dein
b50c6acff0 fix XML parsing (#2)
- Use antchfx/xmlquery for easier XML parsing. No more regexp wrangling and the result is much more reliable over a variety of ebooks. Much good.
- fix chapter selection, look for `<?xml[...]` which is much more reliable
- add option `-x` to dump the XML ebook source for debugging
2025-10-16 18:57:05 +02:00
90d30cb3e1 enhanced wording 2025-10-16 13:03:42 +02:00
f942378b47 fixed copy/paste errors 2025-10-16 13:02:35 +02:00
3d3bbe3266 clarified purpose of epuppy 2025-10-16 12:50:37 +02:00
84307c42eb added contrib and minimal technical coc 2025-10-16 12:50:22 +02:00
47 changed files with 2779 additions and 220 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

@@ -21,9 +21,29 @@ jobs:
- name: build - name: build
run: go build run: go build
test:
strategy:
matrix:
version: [1.24.9]
os: [ubuntu-latest]
name: Test
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go ${{ matrix.os }}
uses: actions/setup-go@v6
with:
go-version: '${{ matrix.version }}'
id: go
- name: checkout
uses: actions/checkout@v5
- name: test
run: go test -cover ./...
golangci: golangci:
name: lint name: Lintercheck
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6

18
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,18 @@
# CODE_OF_CONDUCT.md (compact model)
Purpose: Maintain a collaborative, harassment-free environment focused
on shipping quality software.
Standards: Be respectful; assume good intent; no harassment; no
discrimination; keep critiques technical.
Scope: All project spaces (issues, PRs, forums, events).
Reporting: Email tom AT vondein DOT org. Acknowledge within 72 hours.
Enforcement: Two maintainers review, one recuses on
conflict. Sanctions range from warning to removal. Summary posted (no
personal details).
Escalation: If you believe maintainers handled a report in bad faith,
escalate to our foundation committee (link) for independent review.

94
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,94 @@
## Project Goals
The idea behind this project is to build a small TUI tool to be able
to just take a look into some epub file without the need to leave the
shell. It has to be fast enough to just peak into an ebook and should
be small and easy to understand.
There will be no GUI, no web interface, no public API of some sort, no
builtin interpreter. It is not intended to build a full blown ebook
reader.
The programming language used for this project will always be
[GOLANG](https://go.dev/) with the exception of the documentation
([Perl POD](https://perldoc.perl.org/perlpod)) and the Makefile.
# Contributing
You can contribute to this project in various ways:
## Open an issue
If you encounter a problem or don't understand how the program works
or if you think the documentation is unclear, please don't hesitate to
open an issue.
Please add as much information about the case as possible, such as:
- Your environment (operating system etc)
- program version
- Input data. Please replace sensitive information with mock data!
- Actual program output.
- Expected program output.
- Error message - if any.
Be aware that I am working on this (and some other) project in my
spare time which is scarce. Therefore please don't expect me to
respond to your query within hours or even days. Be patient, but I
WILL respond.
## Pull Requests
Code and documentation help is always much appreciated! Please follow
thes guidelines to successfully contribute:
- Every pull request shall be based on latest `development`
branch. `main` is only used for releases.
- Execute the unit tests before committing: `make test`. There shall
be no errors.
- Strive to be backwards compatible so that users who are already
using the program don't have to change their habits - unless it is
really neccessary.
- Try to add a unit test for your addition.
- Don't ever change existing unit tests!
- Add a meaningful and comprehensive rationale about your contribution:
- Why do you think it might be useful for others?
- What did you actually change or add?
- Is there an open issue which this PR fixes and if so, please link
to that issue.
- [Re-]format your code with `gofmt -s`.
- Avoid unneccesary dependencies, especially for very small functions.
- **If** a new dependency is being added, it must be compatible with
our [license agreement](LICENSE).
- You need to accept that the code or documentation you contribute
will be redistributed under the terms of said license agreement. If
your contribution is considerably large or if you contribute
regularly, then feel free to add your name and if you want your
email address to the *AUTHORS* section of the
manual page.
- Adhere to the above mentioned project goals.
- If you are unsure if your addition or change will be accepted,
better ask before starting coding. Open an issue about your proposal
and let's discuss it! That way we avoid doing unnessesary work on
both sides.
Each pull request will be carefully reviewed and if it is a useful
addition it will be accepted. However, please be prepared that
sometimes a PR will be rejected. The reasons may vary and will be
documented. Perhaps the above guidelines are not matched, or the
addition seems to be not so useful from my perspective, maybe there
are too much changes or there might be changes I don't even
understand.
But whatever happens: your contribution is always welcome!

View File

@@ -1,3 +1,8 @@
[![Actions](https://github.com/tlinden/epuppy/actions/workflows/ci.yaml/badge.svg)](https://github.com/tlinden/epuppy/actions)
[![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://github.com/tlinden/epuppy/blob/master/LICENSE)
[![Go Report Card](https://goreportcard.com/badge/github.com/tlinden/epuppy)](https://goreportcard.com/report/github.com/tlinden/epuppy)
# epuppy - terminal epub reader # epuppy - terminal epub reader
This is a little TUI epub ebook reader. This is a work in progress and This is a little TUI epub ebook reader. This is a work in progress and
@@ -6,6 +11,13 @@ may not work for all EPUB files yet. It uses a modified version of the
unmaintained but the best I could find to parse EPUBs. Find it in the unmaintained but the best I could find to parse EPUBs. Find it in the
`pkg/epub/` directory. `pkg/epub/` directory.
The idea behind this tool is to be able to just take a look into some
epub file without the need to leave the shell. And it had to be fast
enough to just peak into an ebook. However, it is possible to actually
read epub ebooks with epuppy but I'd encourage you to buy a hardware
ebook reader with an e-ink display. It's better for your eyes in the
long run.
## Screenshots ## Screenshots
- Viewing an ebook in dark mode - Viewing an ebook in dark mode
@@ -20,6 +32,66 @@ unmaintained but the best I could find to parse EPUBs. Find it in the
- Showing the help - Showing the help
![Screenshot](https://github.com/TLINDEN/epuppy/blob/main/.github/assets/help.png) ![Screenshot](https://github.com/TLINDEN/epuppy/blob/main/.github/assets/help.png)
## Usage
To read an ebook, just give a filename as argument to `epuppy`.
Add the option `-s` to store and use a previously stored reading
progress.
Sometimes you may be unhappy with the colors. Depending on your
terminal style you can enable dark mode with `-D`, light mode is the
default. You can also configure custom colors in a config file in
`$HOME/.config/epuppy/confit.toml`:
```toml
# color setting for dark mode
colordark = {
body = "#ffffff",
title = "#7cfc00",
chapter = "#ffff00"
}
# color setting for light mode
colorlight = {
body = "#000000",
title = "#8470ff",
chapter = "#00008b"
}
# always use dark mode
dark = true
```
There are also cases where your current terminal just doesn't have the
capabilites for this stuff. I stumbled upon such a case during an SSH
session from my Android phone to a FreeBSD server. For this you can
either just disable colors with `-N` or by setting the environment
variable `$NO_COLOR` to 1. Or you can just dump the text of the ebook
and pipe it to some pager, e.g.:
```default
epuppy -t someebook.epub | less
```
There are also a couple of debug options etc, all options:
```default
Usage epuppy [options] <epub file>
Options:
-D --dark enable dark mode
-s --store-progress remember reading position
-n --line-numbers add line numbers
-c --config <file> use config <file>
-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)
-d --debug enable debugging
-h --help show help message
-v --version show program version
```
## Installation ## Installation
The tool does not have any dependencies. Just download the binary for The tool does not have any dependencies. Just download the binary for
@@ -76,4 +148,4 @@ version 3.
# Author # Author
Copyleft (c) 2024 Thomas von Dein Copyleft (c) 2025 Thomas von Dein

View File

@@ -1,3 +1,19 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd package cmd
import ( import (

View File

@@ -1,3 +1,19 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd package cmd
import ( import (
@@ -16,15 +32,20 @@ import (
) )
const ( const (
Version string = `v0.0.2` Version string = `v0.0.7`
Usage string = `Usage epuppy [options] <epub file> Usage string = `This is epuppy, a terminal ui ebook viewer.
Usage: epuppy [options] <epub file>
Options: Options:
-D --dark enable dark mode -D --dark enable dark mode
-s --store-progress remember reading position -s --store-progress remember reading position
-n --line-numbers add line numbers -n --line-numbers add line numbers
-c --config <file> use config <file> -c --config <file> use config <file>
-i --cover-image display cover image
-t --txt dump readable content to STDOUT -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)
-d --debug enable debugging -d --debug enable debugging
-h --help show help message -h --help show help message
-v --version show program version` -v --version show program version`
@@ -37,10 +58,13 @@ type Config struct {
Darkmode bool `koanf:"dark"` // -D Darkmode bool `koanf:"dark"` // -D
LineNumbers bool `koanf:"line-numbers"` // -n LineNumbers bool `koanf:"line-numbers"` // -n
Dump bool `koanf:"txt"` // -t Dump bool `koanf:"txt"` // -t
XML bool `koanf:"xml"` // -x
NoColor bool `koanf:"no-color"` // -n
Config string `koanf:"config"` // -c Config string `koanf:"config"` // -c
ColorDark ColorSetting `koanf:"colordark"` // comes from config file only ColorDark ColorSetting `koanf:"colordark"` // comes from config file only
ColorLight ColorSetting `koanf:"colorlight"` // 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 Colors Colors // generated from user config file or internal defaults, respects dark mode
Document string Document string
InitialProgress int // lines InitialProgress int // lines
@@ -56,7 +80,6 @@ func InitConfig(output io.Writer) (*Config, error) {
if err != nil { if err != nil {
log.Fatalf("failed to print to output: %s", err) log.Fatalf("failed to print to output: %s", err)
} }
os.Exit(0)
} }
// parse commandline flags // parse commandline flags
@@ -66,6 +89,10 @@ func InitConfig(output io.Writer) (*Config, error) {
flagset.BoolP("store-progress", "s", false, "store reading progress") flagset.BoolP("store-progress", "s", false, "store reading progress")
flagset.BoolP("line-numbers", "n", false, "add line numbers") flagset.BoolP("line-numbers", "n", false, "add line numbers")
flagset.BoolP("txt", "t", false, "dump readable content to STDOUT") 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") flagset.StringP("config", "c", "", "read config from file")
if err := flagset.Parse(os.Args[1:]); err != nil { if err := flagset.Parse(os.Args[1:]); err != nil {
@@ -114,8 +141,9 @@ func InitConfig(output io.Writer) (*Config, error) {
if len(flagset.Args()) > 0 { if len(flagset.Args()) > 0 {
conf.Document = flagset.Args()[0] conf.Document = flagset.Args()[0]
} else { } else {
if !conf.Showversion { if !conf.Showversion && !conf.ShowHelp {
flagset.Usage() flagset.Usage()
os.Exit(1)
} }
} }
@@ -137,6 +165,11 @@ func InitConfig(output io.Writer) (*Config, error) {
}, },
conf) conf)
// disable colors if requested by command line
if conf.NoColor {
_ = os.Setenv("NO_COLOR", "1")
}
return conf, nil return conf, nil
} }

View File

@@ -1,3 +1,19 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd package cmd
// pager setup using bubbletea // pager setup using bubbletea
@@ -5,10 +21,13 @@ package cmd
// https://github.com/charmbracelet/bubbletea/tree/main/examples/pager // https://github.com/charmbracelet/bubbletea/tree/main/examples/pager
import ( import (
"bytes"
"fmt" "fmt"
"image"
"os" "os"
"strings" "strings"
"github.com/blacktop/go-termimg"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@@ -52,12 +71,16 @@ type keyMap struct {
Right key.Binding Right key.Binding
Help key.Binding Help key.Binding
Quit key.Binding Quit key.Binding
ToggleUI key.Binding
Pad key.Binding
} }
func (k keyMap) FullHelp() [][]key.Binding { func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{ return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right}, // first column // every item is one column
{k.Help, k.Quit}, // second column {k.Up, k.Down, k.Left, k.Right},
{k.Pad}, // fake key, we use it as spacing between columns
{k.Help, k.Quit, k.ToggleUI},
} }
} }
@@ -66,6 +89,10 @@ func (k keyMap) ShortHelp() []key.Binding {
} }
var keys = keyMap{ var keys = keyMap{
Pad: key.NewBinding(
key.WithKeys("__"),
key.WithHelp(" ", ""),
),
Up: key.NewBinding( Up: key.NewBinding(
key.WithKeys("up", "k"), key.WithKeys("up", "k"),
key.WithHelp("↑", "move up"), key.WithHelp("↑", "move up"),
@@ -75,11 +102,11 @@ var keys = keyMap{
key.WithHelp("↓", "move down"), key.WithHelp("↓", "move down"),
), ),
Left: key.NewBinding( Left: key.NewBinding(
key.WithKeys("left", "h"), key.WithKeys("left"),
key.WithHelp("←", "decrease text width"), key.WithHelp("←", "decrease text width"),
), ),
Right: key.NewBinding( Right: key.NewBinding(
key.WithKeys("right", "l"), key.WithKeys("right"),
key.WithHelp("→", "increase text width"), key.WithHelp("→", "increase text width"),
), ),
Help: key.NewBinding( Help: key.NewBinding(
@@ -90,6 +117,10 @@ var keys = keyMap{
key.WithKeys("q", "esc", "ctrl+c"), key.WithKeys("q", "esc", "ctrl+c"),
key.WithHelp("q", "quit"), key.WithHelp("q", "quit"),
), ),
ToggleUI: key.NewBinding(
key.WithKeys("h"),
key.WithHelp("h", "toggle ui"),
),
} }
type Doc struct { type Doc struct {
@@ -101,8 +132,10 @@ type Doc struct {
lastwidth int lastwidth int
margin int margin int
marginMod bool marginMod bool
hideUi bool
meta *Meta meta *Meta
config *Config config *Config
Cover *termimg.ImageWidget
keys keyMap keys keyMap
help help.Model help help.Model
@@ -135,6 +168,8 @@ func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.margin -= MarginStep m.margin -= MarginStep
m.marginMod = true m.marginMod = true
} }
case key.Matches(msg, m.keys.ToggleUI):
m.hideUi = !m.hideUi
} }
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@@ -189,7 +224,12 @@ func (m *Doc) Rewrap() {
// wrapping has changed, update viewport and line count // wrapping has changed, update viewport and line count
if content != "" { if content != "" {
if m.Cover != nil {
cover, _ := m.Cover.Render()
m.viewport.SetContent(cover + "\n\n" + content)
} else {
m.viewport.SetContent(content) m.viewport.SetContent(content)
}
m.meta.lines = len(strings.Split(content, "\n")) m.meta.lines = len(strings.Split(content, "\n"))
} }
@@ -212,6 +252,10 @@ func (m Doc) View() string {
helpView = "\n" + m.help.View(m.keys) helpView = "\n" + m.help.View(m.keys)
} }
if m.hideUi {
return fmt.Sprintf("%s\n%s", m.viewport.View(), helpView)
}
return fmt.Sprintf("%s\n%s\n%s%s", m.headerView(), m.viewport.View(), m.footerView(), helpView) return fmt.Sprintf("%s\n%s\n%s%s", m.headerView(), m.viewport.View(), m.footerView(), helpView)
} }
@@ -235,7 +279,15 @@ func max(a, b int) int {
return b 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 width := 80
scrollto := 0 scrollto := 0
@@ -246,32 +298,44 @@ func Pager(conf *Config, title, message string) (int, error) {
} }
} }
if conf.StoreProgress { if book.Config.StoreProgress {
scrollto = conf.InitialProgress scrollto = book.Config.InitialProgress
} }
if conf.LineNumbers { if book.Config.LineNumbers {
catn := "" catn := ""
for idx, line := range strings.Split(message, "\n") { for idx, line := range strings.Split(book.Body, "\n") {
catn += fmt.Sprintf("%d: %s\n", idx, line) catn += fmt.Sprintf("%4d: %s\n", idx, line)
} }
message = catn book.Body = catn
} }
meta := Meta{ meta := Meta{
initialprogress: scrollto, 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( p := tea.NewProgram(
Doc{ doc,
content: message,
title: title,
initialwidth: width,
meta: &meta,
config: conf,
keys: keys,
},
tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer" 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 tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel
) )
@@ -280,7 +344,7 @@ func Pager(conf *Config, title, message string) (int, error) {
return 0, fmt.Errorf("could not run pager: %w", err) 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", fmt.Printf("scrollto: %d, last: %d, diff: %d\n",
scrollto, meta.currentline, scrollto-meta.currentline) scrollto, meta.currentline, scrollto-meta.currentline)
} }

119
cmd/prepare.go Normal file
View File

@@ -0,0 +1,119 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/alecthomas/repr"
"github.com/tlinden/epuppy/pkg/epub"
)
func Prepare(conf *Config) (*Ebook, error) {
switch filepath.Ext(conf.Document) {
case ".epub":
return PrepareEpub(conf)
default:
return PrepareText(conf)
}
}
func PrepareText(conf *Config) (*Ebook, error) {
data, err := os.ReadFile(conf.Document)
if err != nil {
return nil, err
}
return &Ebook{
Config: conf,
Title: conf.Document,
Body: string(data),
}, nil
}
func PrepareEpub(conf *Config) (*Ebook, error) {
book, err := epub.Open(conf.Document, conf.XML)
if err != nil {
return nil, err
}
if conf.Debug {
repr.Println("book.Files()")
repr.Println(book.Files())
repr.Println(book.Ncx)
repr.Println(book.Sections)
repr.Println(book.Opf.Manifest)
}
buf := strings.Builder{}
head := strings.Builder{}
for _, creator := range book.Opf.Metadata.Creator {
head.WriteString(creator.Data)
head.WriteString(" ")
}
head.WriteString("- ")
for _, title := range book.Opf.Metadata.Title {
head.WriteString(title)
head.WriteString(" ")
}
fetchByContent(conf, &buf, book)
return &Ebook{
Config: conf,
Title: head.String(),
Body: buf.String(),
Cover: book.CoverImage,
MediaType: book.CoverMediaType,
}, nil
}
func fetchByContent(conf *Config, buf *strings.Builder, book *epub.Book) bool {
chapter := 1
var gotbody bool
for _, content := range book.Content {
if len(content.Body) > 0 {
if content.Title != "" {
buf.WriteString(conf.Colors.Chapter.
Render(fmt.Sprintf("──────┤ %s ├──────", content.Title)))
}
buf.WriteString("\r\n\r\n")
if conf.Dump {
// avoid excess whitespaces when printing to stdout
buf.WriteString(content.Body)
} else {
buf.WriteString(conf.Colors.Body.Render(content.Body))
}
buf.WriteString("\r\n\r\n\r\n\r\n")
chapter++
gotbody = true
}
}
return gotbody
}

View File

@@ -1,3 +1,19 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd package cmd
import ( import (
@@ -27,6 +43,15 @@ func Execute(output io.Writer) int {
return 0 return 0
} }
if conf.ShowHelp {
_, err := fmt.Fprintln(output, Usage)
if err != nil {
return Die(fmt.Errorf("failed to print to output: %s", err))
}
return 0
}
if conf.StoreProgress { if conf.StoreProgress {
progress, err := GetProgress(conf) progress, err := GetProgress(conf)
if err == nil { if err == nil {
@@ -34,7 +59,21 @@ func Execute(output io.Writer) int {
} }
} }
progress, err := View(conf) ebook, err := Prepare(conf)
if err != nil {
return Die(err)
}
if conf.Dump {
fmt.Println(ebook.Body)
return 0
}
if conf.Debug || conf.XML {
return 0
}
progress, err := Pager(ebook)
if err != nil { if err != nil {
return Die(err) return Die(err)
} }

View File

@@ -1,3 +1,19 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd package cmd
import ( import (

View File

@@ -1,93 +0,0 @@
package cmd
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"github.com/tlinden/epuppy/pkg/epub"
)
func View(conf *Config) (int, error) {
switch filepath.Ext(conf.Document) {
case ".epub":
return ViewEpub(conf)
default:
return ViewText(conf)
}
}
func ViewText(conf *Config) (int, error) {
data, err := os.ReadFile(conf.Document)
if err != nil {
return 0, err
}
if conf.Dump {
return fmt.Println(string(data))
}
return Pager(conf, conf.Document, string(data))
}
func ViewEpub(conf *Config) (int, error) {
book, err := epub.Open(conf.Document)
if err != nil {
return 0, err
}
defer func() {
if err := book.Close(); err != nil {
log.Fatal(err)
}
}()
buf := strings.Builder{}
head := strings.Builder{}
for _, creator := range book.Opf.Metadata.Creator {
head.WriteString(creator.Data)
head.WriteString(" ")
}
head.WriteString("- ")
for _, title := range book.Opf.Metadata.Title {
head.WriteString(title)
head.WriteString(" ")
}
// FIXME: since the switch to book.Files() in epub.Open() this
// returns invalid chapter numbering
chapter := 1
for _, content := range book.Content {
if len(content.Body) > 0 {
if content.Title != "" {
buf.WriteString(conf.Colors.Chapter.
Render(fmt.Sprintf("──────┤ %s ├──────", content.Title)))
}
buf.WriteString("\r\n\r\n")
if conf.Dump {
// avoid excess whitespaces when printing to stdout
buf.WriteString(content.Body)
} else {
buf.WriteString(conf.Colors.Body.Render(content.Body))
}
buf.WriteString("\r\n\r\n\r\n\r\n")
chapter++
}
}
if conf.Dump {
return fmt.Println(buf.String())
}
return Pager(conf, head.String(), buf.String())
}

118
cmd/view_test.go Normal file
View File

@@ -0,0 +1,118 @@
package cmd
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPrepare(t *testing.T) {
var tests = []struct {
file string
body string
}{
{
"t/epub/basic-v3plus2.epub",
`shirt court, an whinny retched a cordage offer groin-murder, picked inner windrow,`,
},
{
"t/epub/childrens-literature.epub",
`The child's natural literature. The world has lost certain secrets as the price of an advancing civilization. It is a commonplace of observation that no one can duplicate the success of Mother Goose, whether she be thought of as the maker of jingles or the teller of tales. The conditions of modern life preclude the generally naïve attitude that produced the folk rhymes, ballads, tales, proverbs, fables, and myths. The folk saw things simply and directly. The complex, analytic, questioning mind is not yet, either in or out of stories. The motives from which people act are to them plain and not mixed. Characters are good or bad. They feel no need of elaborately explaining their joys and sorrows. Such experiences come with the day's work. "To-morrow to fresh woods, and pastures new." The zest of life with them is emphatic. Their humor is fresh, unbounded, sincere; there is no trace of cynicism. In folk literature we do not feel the presence of a "writer" who is mightily concerned about maintaining his reputation for wisdom, originality, or style. Hence the freedom from any note of straining after effect, of artificiality. In the midst of a life limited to fundamental needs, their literature deals with fundamentals. On the whole, it was a literature for entertainment. A more learned upper class may have concerned itself then about "problems" and "purposes," as the whole world does now, but the literature of the folk had no such interests.`,
},
{
"t/epub/cole-voyage-of-life.epub",
`Thomas Cole is regarded as the founder of the Hudson River School, an American art movement that flourished in the mid-19th century and was concerned with the realistic and detailed portrayal of nature but with a strong influence from Romanticism. This group of American landscape painters worked between about 1825 and 1870 and shared a sense of national pride as well as an interest in celebrating the unique natural beauty found in the United States. The wild, untamed nature found in America was viewed as its special character; Europe had ancient ruins, but America had the uncharted wilderness. As Cole's friend William Cullen Bryant sermonized in verse, so Cole sermonized in paint. Both men saw nature as God's work and as a refuge from the ugly materialism of cities. Cole clearly intended the Voyage of Life to be a didactic, moralizing series of paintings using the landscape as an allegory for religious faith.`,
},
{
"t/epub/epub30-spec.epub",
`IDPF Members
Invited Experts/Observers
Version 2.0.1 of this specification was prepared by the International Digital Publishing Forums EPUB Maintenance Working Group under the leadership of:
Active members of the working group at the time of publication of revision 2.0.1 were:
Version 1.0 of this specification was prepared by the International Digital Publishing Forums Unified OEBPS Container Format Working Group under the leadership of:
Active members of the working group at the time of publication of revision 1.0 were:`,
},
{
"t/epub/epub_sample_file_50KB.epub",
`magna aliqua. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna`,
},
{
"t/epub/Fundamental-Accessibility-Tests-Basic-Functionality-v2.0.0.epub",
`Open the Fundamental Accessibility Test book in the reading system.
If the test book is not available in the bookshelf, then open any other book that is available.
If the reading system also supports side loading, then please provide notes about the accessibility of the side loading feature.
Indicate Pass or Fail.`,
},
{
"t/epub/georgia-cfi.epub",
`The Great Valley Region consists of folded sedimentary rocks, extensive erosion having removed the soft layers to form valleys, leaving the hard layers as ridges, both layers running in a N.E.-S.W. direction. In the extreme north-west corner of the state is a small part of the Cumberland Plateau, represented by Lookout and Sand Mts.
On the Blue Ridge escarpment near the N.E. corner of the state is a water-parting separating the waters which find their way respectively N.W. to the Tennessee river, S.W. to the Gulf of Mexico and S.E. to the Atlantic Ocean; indeed, according to B.M. and M.R. Hall (Water Resources of Georgia, p. 2), "there are three springs in north-east Georgia within a stone's throw of each other that send out their waters to Savannah, Ga., to Apalachicola, Fla., and to New Orleans, La." The water-parting between the waters flowing into the`,
},
{
"t/epub/israelsailing.epub",
` במשלוח דואר, מה שלפעמים היה נחמד כי גם חשבונות לתשלום לא היו מגיעים. 'טוב, מי`,
},
{
"t/epub/jlreq-in-japanese.epub",
` 2.5.1 基本版面からはみ出す例 2.5.2 基本版面で設定した行位置の適用 2.5.3 `,
},
{
"t/epub/minimal-v2.epub",
`This is a paragraph.`,
},
{
"t/epub/minimal-v3.epub",
`This is a paragraph.`,
},
{
"t/epub/minimal-v3plus2.epub",
`This is a paragraph.`,
},
{
"t/epub/moby-dick.epub",
`Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world. It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every funeral I meet; and especially whenever my hypos get such an upper hand of me, that it requires a strong moral principle to prevent me from deliberately stepping into the street, and methodically knocking peoples hats off—then, I account it high time to get to sea as soon as I can. This is my substitute for pistol and ball. With a philosophical flourish Cato throws himself upon his sword; I quietly take to the ship. There is nothing surprising in this. If they but knew it, almost all men in their degree, some time or other, cherish very nearly the same feelings towards the ocean with me.`,
},
{
"t/epub/sous-le-vent.epub",
`SOUS LE VENT`,
},
{
"t/epub/wasteland-otf.epub",
`Line 20. Cf. Ezekiel 2:1.
23. Cf. Ecclesiastes 12:5.
31. V. Tristan und Isolde, i, verses 5-8.
42. Id. iii, verse 24.`,
},
{
"pkg/epub/test.epub",
`This EPUB file contains 10 hard coded page breaks. Note that these page breaks are different from the reflowed page numbers. If the total number of pages of this book in the reading app is not exactly 10, then you are looking at the reflowed pages. The app may be having a feature to switch between print page and reflowed pages for navigation. Or the print page list may be appearing in the TOC. If print page navigation feature does not exist or does not work then this test should be marked 'Fail'.`,
},
}
for _, tt := range tests {
testname := fmt.Sprintf("prepare/%s", tt.file)
t.Run(testname, func(t *testing.T) {
conf := Config{
Document: "../" + tt.file,
}
ebook, err := Prepare(&conf)
assert.NoError(t, err)
assert.Contains(t, ebook.Body, tt.body, "expected text not found")
})
}
}

19
go.mod
View File

@@ -6,6 +6,7 @@ toolchain go1.24.9
require ( require (
github.com/alecthomas/repr v0.5.2 github.com/alecthomas/repr v0.5.2
github.com/antchfx/xmlquery v1.5.0
github.com/knadh/koanf/parsers/toml v0.1.0 github.com/knadh/koanf/parsers/toml v0.1.0
github.com/knadh/koanf/providers/file v1.2.0 github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/providers/posflag v1.0.1 github.com/knadh/koanf/providers/posflag v1.0.1
@@ -35,14 +36,30 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.3.8 // indirect golang.org/x/text v0.30.0 // indirect
) )
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/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.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/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/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // 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/pelletier/go-toml v1.9.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/soniakeys/quant v1.0.0 // indirect
github.com/stretchr/testify v1.11.1 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

103
go.sum
View File

@@ -1,9 +1,15 @@
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 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 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -18,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/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 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 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 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -28,6 +37,9 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI= github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI=
@@ -40,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/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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -47,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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 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-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 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -59,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/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 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= 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= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -67,21 +85,102 @@ github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
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 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 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=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

16
main.go
View File

@@ -1,3 +1,19 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package main package main
import ( import (

38
main_test.go Normal file
View File

@@ -0,0 +1,38 @@
/*
Copyright © 2025 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package main
import (
"testing"
"github.com/rogpeppe/go-internal/testscript"
)
// see https://bitfieldconsulting.com/golang/test-scripts
func TestMain(m *testing.M) {
testscript.Main(m, map[string]func(){
"epuppy": main,
})
}
func TestEpuppy(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "t",
})
}

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# Copyright © 2024 Thomas von Dein # Copyright © 2025 Thomas von Dein
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by # it under the terms of the GNU General Public License as published by

21
pkg/epub/README.md Normal file
View File

@@ -0,0 +1,21 @@
# epub module
This is a modified version of the [epub
module](https://github.com/kapmahc/epub/). I fixed a couple of issues
and I added code to retrieve the actual epub content, which the
original module doesn't implement. Since the last update on that
module was 9 years ago (as I write this), it doesn't make sense to
send a PR. And it also makes no sense to me to publish it separately.
# ORIGINAL README
## epub
A pure go implementation of epub file format.
### Documents
- <http://idpf.org/epub>
- <http://www.cnblogs.com/Alex80/p/5127104.html>
- <http://www.cnblogs.com/diligenceday/p/4999315.html>

View File

@@ -9,6 +9,11 @@ import (
"path" "path"
) )
// a section in the book
type Section struct {
File, Title, MediaType string
}
// Book epub book // Book epub book
type Book struct { type Book struct {
Ncx Ncx `json:"ncx"` Ncx Ncx `json:"ncx"`
@@ -16,8 +21,12 @@ type Book struct {
Container Container `json:"-"` Container Container `json:"-"`
Mimetype string `json:"-"` Mimetype string `json:"-"`
Content []Content Content []Content
fd *zip.ReadCloser fd *zip.ReadCloser
CoverImage []byte
CoverFile string
CoverMediaType string
Sections []Section
dumpxml bool
} }
// Open open resource file // Open open resource file
@@ -34,11 +43,6 @@ func (p *Book) Files() []string {
return fns return fns
} }
// Close close file reader
func (p *Book) Close() error {
return p.fd.Close()
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func (p *Book) filename(n string) string { func (p *Book) filename(n string) string {
return path.Join(path.Dir(p.Container.Rootfile.Path), n) return path.Join(path.Dir(p.Container.Rootfile.Path), n)

View File

@@ -1,11 +1,11 @@
package epub package epub
//Container META-INF/container.xml file // Container META-INF/container.xml file
type Container struct { type Container struct {
Rootfile Rootfile `xml:"rootfiles>rootfile" json:"rootfile"` Rootfile Rootfile `xml:"rootfiles>rootfile" json:"rootfile"`
} }
//Rootfile root file // Rootfile root file
type Rootfile struct { type Rootfile struct {
Path string `xml:"full-path,attr" json:"path"` Path string `xml:"full-path,attr" json:"path"`
Type string `xml:"media-type,attr" json:"type"` Type string `xml:"media-type,attr" json:"type"`

View File

@@ -1,19 +1,18 @@
package epub package epub
import ( import (
"encoding/xml"
"fmt"
"regexp" "regexp"
"strings" "strings"
"github.com/antchfx/xmlquery"
) )
var ( var (
cleantitle = regexp.MustCompile(`(?s)<head>.*</head>`) cleanentitles = regexp.MustCompile(`&[a-z]+;`)
empty = regexp.MustCompile(`(?s)^[\s ]*$`)
newlines = regexp.MustCompile(`[\r\n\s]+`)
cleansvg = regexp.MustCompile(`(<svg.+</svg>|<!\[CDATA\[.+\]\]>)`)
cleanmarkup = regexp.MustCompile(`<[^<>]+>`) cleanmarkup = regexp.MustCompile(`<[^<>]+>`)
cleanentities = regexp.MustCompile(`&.+;`)
cleancomments = regexp.MustCompile(`/*.*/`)
cleanspace = regexp.MustCompile(`^\s*`)
cleanh1 = regexp.MustCompile(`<h[1-6].*</h[1-6]>`)
) )
// Content nav-point content // Content nav-point content
@@ -25,26 +24,47 @@ type Content struct {
XML []byte XML []byte
} }
// parse XML, look for title and <p>.*</p> stuff
func (c *Content) String(content []byte) error { func (c *Content) String(content []byte) error {
title := Title{} doc, err := xmlquery.Parse(
strings.NewReader(
err := xml.Unmarshal(content, &title) cleansvg.ReplaceAllString(
cleanentitles.ReplaceAllString(string(content), " "), "")))
if err != nil { if err != nil {
if !strings.HasPrefix(err.Error(), "XML syntax error") { return err
return fmt.Errorf("XML parser error %w", err) }
if c.Title == "" {
// extract the title
for _, item := range xmlquery.Find(doc, "//title") {
c.Title = strings.TrimSpace(item.InnerText())
} }
} }
c.Title = strings.TrimSpace(title.Content) // extract all paragraphs, ignore any formatting and re-fill the
// paragraph, that is, we replace all newlines inside with one
// space.
txt := strings.Builder{}
var have_p bool
for _, item := range xmlquery.Find(doc, "//p") {
if !empty.MatchString(item.InnerText()) {
have_p = true
txt.WriteString(newlines.ReplaceAllString(item.InnerText(), " ") + "\n\n")
}
}
txt := cleantitle.ReplaceAllString(string(content), "") if !have_p {
txt = cleanh1.ReplaceAllString(txt, "") // try <div></div>, which some ebooks use, so get all divs,
txt = cleanmarkup.ReplaceAllString(txt, "") // remove markup and paragraphify the parts
txt = cleanentities.ReplaceAllString(txt, " ") for _, item := range xmlquery.Find(doc, "//div") {
txt = cleancomments.ReplaceAllString(txt, "") if !empty.MatchString(item.InnerText()) {
txt = strings.TrimSpace(txt) cleaned := cleanmarkup.ReplaceAllString(item.InnerText(), "")
txt.WriteString(newlines.ReplaceAllString(cleaned, " ") + "\n\n")
}
}
}
c.Body = cleanspace.ReplaceAllString(txt, "") c.Body = strings.TrimSpace(txt.String())
c.XML = content c.XML = content
if len(c.Body) == 0 { if len(c.Body) == 0 {

View File

@@ -1,36 +1,22 @@
package epub package epub
import ( import (
"log"
"testing" "testing"
) )
func TestEpub(t *testing.T) { func TestEpub(t *testing.T) {
bk, err := open(t, "test.epub") _, err := open(t, "test.epub")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer func() {
if err := bk.Close(); err != nil {
log.Fatal(err)
}
}()
} }
func open(t *testing.T, f string) (*Book, error) { func open(t *testing.T, f string) (*Book, error) {
bk, err := Open(f) bk, err := Open(f, false)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() {
if err := bk.Close(); err != nil {
log.Fatal(err)
}
}()
t.Logf("files: %+v", bk.Files()) t.Logf("files: %+v", bk.Files())
t.Logf("book: %+v", bk) t.Logf("book: %+v", bk)

View File

@@ -8,6 +8,7 @@ type Ncx struct {
// NavPoint nav point // NavPoint nav point
type NavPoint struct { type NavPoint struct {
Text string `xml:"navLabel>text" json:"text"` Text string `xml:"navLabel>text" json:"text"`
Content Content `xml:"content" json:"content"`
Points []NavPoint `xml:"navPoint" json:"points"` Points []NavPoint `xml:"navPoint" json:"points"`
} }

View File

@@ -2,68 +2,204 @@ package epub
import ( import (
"archive/zip" "archive/zip"
"fmt"
"log" "log"
"path/filepath"
"regexp"
"strings" "strings"
) )
// Open open a epub file var (
func Open(fn string) (*Book, error) { // to find content
types = regexp.MustCompile(`application/(xml|html|xhtml|htm)`)
// cleanup regexes
deanchor = regexp.MustCompile(`#.*$`)
cleanext = regexp.MustCompile(`^\.`)
)
// Open open a epub file and return the filled Book structure
func Open(fn string, dumpxml bool) (*Book, error) {
bk, err := openFile(fn, dumpxml)
if err != nil {
return bk, err
}
defer func() {
if err := bk.fd.Close(); err != nil {
log.Fatal(err)
}
}()
if err := bk.getManifest(); err != nil {
return bk, err
}
if err := bk.getSections(); err != nil {
return bk, err
}
if err := bk.readSectionContent(); err != nil {
return bk, err
}
return bk, nil
}
// load the epub zip file
func openFile(fn string, dumpxml bool) (*Book, error) {
fd, err := zip.OpenReader(fn) fd, err := zip.OpenReader(fn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() { bk := &Book{fd: fd, dumpxml: dumpxml}
if err := fd.Close(); err != nil {
log.Fatal(err)
}
}()
bk := Book{fd: fd} return bk, nil
}
// load the manifest
func (bk *Book) getManifest() error {
mt, err := bk.readBytes("mimetype") mt, err := bk.readBytes("mimetype")
if err != nil { if err != nil {
return &bk, err return err
} }
bk.Mimetype = string(mt) bk.Mimetype = string(mt)
// contains the root path
err = bk.readXML("META-INF/container.xml", &bk.Container) err = bk.readXML("META-INF/container.xml", &bk.Container)
if err != nil { if err != nil {
return &bk, err return err
} }
// contains the OPF data
err = bk.readXML(bk.Container.Rootfile.Path, &bk.Opf) err = bk.readXML(bk.Container.Rootfile.Path, &bk.Opf)
if err != nil { if err != nil {
return &bk, err return err
} }
// look for TOC (might be incomplete, see below!)
for _, mf := range bk.Opf.Manifest { for _, mf := range bk.Opf.Manifest {
if mf.ID == bk.Opf.Spine.Toc { if mf.ID == bk.Opf.Spine.Toc {
err = bk.readXML(bk.filename(mf.Href), &bk.Ncx) err = bk.readXML(bk.filename(mf.Href), &bk.Ncx)
if err != nil { if err != nil {
return &bk, err return err
}
} }
if mf.ID == "cover-image" {
bk.CoverFile = mf.Href
bk.CoverMediaType = mf.MediaType
}
}
return nil
}
// extract the readable sections of the epub
func (bk *Book) getSections() error {
// to store our final content sections
sections := []Section{}
// count the content items in the raw manifest
var manifestcount int
for _, item := range bk.Opf.Manifest {
if types.MatchString(item.MediaType) {
manifestcount++
}
}
// we have ncx points from the TOC, try those
if len(bk.Ncx.Points) > 0 {
for _, block := range bk.Ncx.Points {
sect := Section{
File: "OEBPS/" + block.Content.Src,
Title: block.Text,
}
srcfile := deanchor.ReplaceAllString(block.Content.Src, "")
for _, file := range bk.Files() {
if strings.Contains(file, srcfile) {
sect.File = file
sect.MediaType = "application/" + cleanext.ReplaceAllString(filepath.Ext(file), "")
break break
} }
} }
sections = append(sections, sect)
}
if len(sections) < manifestcount {
// TOC was incomplete, restart from scratch but use the
// OPF Manifest directly
sections = []Section{}
for _, item := range bk.Opf.Manifest {
if types.MatchString(item.MediaType) {
sect := Section{
File: "OEBPS/" + item.Href,
MediaType: item.MediaType,
}
srcfile := deanchor.ReplaceAllString(item.Href, "")
for _, file := range bk.Files() { for _, file := range bk.Files() {
content, err := bk.readBytes(file) if strings.Contains(file, srcfile) {
sect.File = file
break
}
}
sections = append(sections, sect)
}
}
}
} else {
// no TOC, just pull in the files directly
for _, file := range bk.Files() {
sections = append(sections,
Section{
File: file,
MediaType: "application/" + cleanext.ReplaceAllString(filepath.Ext(file), ""),
})
}
}
// final sections to keep
bk.Sections = sections
return nil
}
func (bk *Book) readSectionContent() error {
// now read in the actual xml contents
for _, section := range bk.Sections {
content, err := bk.readBytes(section.File)
if err != nil { if err != nil {
return &bk, err return err
} }
ct := Content{Src: file} if strings.Contains(section.File, bk.CoverFile) {
bk.CoverImage = content
}
if strings.Contains(string(content), "DOCTYPE") { ct := Content{Src: section.File, Title: section.Title}
if types.MatchString(section.MediaType) {
if err := ct.String(content); err != nil { if err := ct.String(content); err != nil {
return &bk, err return err
} }
} }
if bk.dumpxml {
fmt.Println(string(ct.XML))
}
bk.Content = append(bk.Content, ct) bk.Content = append(bk.Content, ct)
} }
return &bk, nil return nil
} }

View File

@@ -1,13 +1,13 @@
package epub package epub
//Opf content.opf // Opf content.opf
type Opf struct { type Opf struct {
Metadata Metadata `xml:"metadata" json:"metadata"` Metadata Metadata `xml:"metadata" json:"metadata"`
Manifest []Manifest `xml:"manifest>item" json:"manifest"` Manifest []Manifest `xml:"manifest>item" json:"manifest"`
Spine Spine `xml:"spine" json:"spine"` Spine Spine `xml:"spine" json:"spine"`
} }
//Metadata metadata // Metadata metadata
type Metadata struct { type Metadata struct {
Title []string `xml:"title" json:"title"` Title []string `xml:"title" json:"title"`
Language []string `xml:"language" json:"language"` Language []string `xml:"language" json:"language"`
@@ -53,7 +53,7 @@ type Metafield struct {
Content string `xml:"content,attr" json:"content"` Content string `xml:"content,attr" json:"content"`
} }
//Manifest manifest // Manifest manifest
type Manifest struct { type Manifest struct {
ID string `xml:"id,attr" json:"id"` ID string `xml:"id,attr" json:"id"`
Href string `xml:"href,attr" json:"href"` Href string `xml:"href,attr" json:"href"`

BIN
pkg/epub/test.epub Normal file

Binary file not shown.

7
t/basic.txtar Normal file
View File

@@ -0,0 +1,7 @@
# no args shall fail
! exec epuppy
# help
exec epuppy -h
stdout 'This is epuppy'

BIN
t/epub/basic-v3plus2.epub Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
t/epub/epub30-spec.epub Normal file

Binary file not shown.

Binary file not shown.

BIN
t/epub/georgia-cfi.epub Normal file

Binary file not shown.

BIN
t/epub/israelsailing.epub Normal file

Binary file not shown.

Binary file not shown.

BIN
t/epub/minimal-v2.epub Normal file

Binary file not shown.

BIN
t/epub/minimal-v3.epub Normal file

Binary file not shown.

BIN
t/epub/minimal-v3plus2.epub Normal file

Binary file not shown.

BIN
t/epub/moby-dick.epub Normal file

Binary file not shown.

BIN
t/epub/sous-le-vent.epub Normal file

Binary file not shown.

BIN
t/epub/wasteland-otf.epub Normal file

Binary file not shown.

1698
t/read-parse.txtar Normal file

File diff suppressed because it is too large Load Diff