main.go
// Command epub is a self-contained EPUB reader: it builds a small sample book as
// a real .epub (zip + OPF + XHTML) in memory, parses it through the full pipeline
// (see epub.go), and renders a typeset reader — a title page, a chapter table of
// contents, and a scrollable chapter view with prev/next. No network, no files:
// it runs identically on desktop, web, and mobile, and shows off gophics' own
// text layout (wrapping, headings, long-form reading).
//
// go run ./examples/epub
package main
import (
"fmt"
"log"
"os"
"strconv"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/gofont/goregular"
"github.com/doug/gophics/app"
"github.com/doug/gophics/geom"
"github.com/doug/gophics/layout"
"github.com/doug/gophics/shell"
"github.com/doug/gophics/theme"
"github.com/doug/gophics/widget"
)
// book is parsed once at startup from the bundled sample epub.
var book = loadBook()
// BG is the window background used at Start, before a widget context exists (the
// mobile bind / app.Config passes it as Config.Background). Inside the tree every
// color comes from theme.Of(ctx), so the reader follows the platform light/dark
// scheme for free — the light theme's warm off-white doubles as the reading paper.
var BG = theme.Light().Bg
// maxColW keeps the text column at a comfortable measure on wide windows.
const maxColW = 680
type App struct{}
func (App) Build(ctx widget.Ctx) widget.Widget {
// Resolve the theme from the platform color scheme and provide it to the tree,
// so every page below reads colors with theme.Of(ctx) and the whole reader
// follows light/dark automatically.
th := theme.Auto(ctx)
// EPUB_CHAPTER deep-links straight to a chapter — for screenshots/thumbnails.
home := widget.Widget(library{})
if v := os.Getenv("EPUB_CHAPTER"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i >= 0 && i < len(book.Chapters) {
home = reader{idx: i}
}
}
return widget.Provide[theme.Theme]{
Value: th,
Child: widget.Fill{Color: th.Bg, Child: frame(widget.Navigator{Home: home})},
}
}
// frame centres content and caps its width for readability, filling narrower
// screens. The reading background (provided by the root Fill) shows on either side.
func frame(child widget.Widget) widget.Widget {
return widget.LayoutBuilder{Build: func(cs layout.Constraints) widget.Widget {
w := cs.Max.W
if w > maxColW {
w = maxColW
}
return widget.Flex{Axis: layout.Horizontal, MainAlign: layout.MainCenter, CrossAlign: layout.CrossStretch,
Children: []widget.Widget{widget.Sized{W: w, Child: child}}}
}}
}
// --- library: title page + table of contents (Navigator Home) ---
type library struct{}
func (library) CreateState() widget.State { return &libraryState{} }
type libraryState struct {
widget.StateBase[library]
loadErr bool
}
// open pops the platform's file picker (ctx.FilePicker — a file dialog on web,
// native panels on desktop, the document picker on mobile) and, on success,
// swaps in the loaded book and rebuilds. The bundled sample stays put on cancel
// or a parse error, so the demo is never left empty.
func (s *libraryState) open(fp shell.FilePicker) {
if fp == nil {
return
}
fp.Open(shell.OpenOptions{Accept: []string{".epub", "application/epub+zip"}},
func(files []shell.PickedFile, err error) {
if err != nil {
s.SetState(func() { s.loadErr = true })
return
}
if len(files) == 0 {
return // cancelled
}
b, perr := parseEPUB(files[0].Data)
if perr != nil {
s.SetState(func() { s.loadErr = true })
return
}
s.SetState(func() {
book = b
s.loadErr = false
})
})
}
func (s *libraryState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
nav := ctx.MustOf[widget.Nav]()
kids := []widget.Widget{
widget.Padding{Insets: geom.Insets{Top: 56, Bottom: 8}, Child: widget.Text{Value: book.Title, Font: theme.FontBold, Size: th.Type.Display, Color: th.Text, Wrap: true}},
widget.Padding{Insets: geom.Insets{Bottom: 20}, Child: widget.Text{Value: "by " + book.Author, Size: th.Type.Body, Color: th.Muted}},
}
// Only offer "Open EPUB…" where the platform actually has a file picker.
if fp := ctx.FilePicker(); fp != nil {
kids = append(kids, widget.Row(theme.Button{Label: "Open EPUB…", OnTap: func() { s.open(fp) }}, widget.Spacer()))
}
if s.loadErr {
kids = append(kids, widget.Padding{Insets: geom.Insets{Top: 8}, Child: widget.Text{Value: "Couldn't read that file as an EPUB.", Size: th.Type.Label, Color: th.Muted}})
}
kids = append(kids,
widget.Padding{Insets: geom.Insets{Top: 32, Bottom: 0}, Child: widget.Text{Value: "CONTENTS", Font: theme.FontBold, Size: th.Type.Label, Color: th.Primary}},
widget.Padding{Insets: geom.Insets{Top: 8}, Child: divider(th)},
)
for i, ch := range book.Chapters {
idx := i
kids = append(kids, tocRow(th, i+1, ch.Title, func() { nav.Push(reader{idx: idx}) }), divider(th))
}
return widget.Fill{Color: th.Bg, Child: widget.Scroll{Child: widget.Padding{
Insets: geom.InsetsSymmetric(28, 12),
Child: widget.Flex{CrossAlign: layout.CrossStretch, Children: kids},
}}}
}
func tocRow(th theme.Theme, n int, title string, onTap func()) widget.Widget {
return widget.Interactive{Gestures: widget.Gestures{OnTap: onTap}, Child: widget.Padding{
Insets: geom.InsetsSymmetric(2, 15),
Child: widget.Row(
widget.Sized{W: 34, Child: widget.Text{Value: fmt.Sprintf("%d", n), Size: th.Type.Body, Color: th.Primary}},
widget.Expand(widget.Text{Value: title, Size: th.Type.Heading, Color: th.Text, Wrap: true}),
),
}}
}
// --- reader: one chapter, scrollable, with prev/next ---
type reader struct{ idx int }
func (reader) CreateState() widget.State { return &readerState{} }
type readerState struct {
widget.StateBase[reader]
idx int
}
func (s *readerState) Init(widget.Ctx) { s.idx = s.W().idx }
func (s *readerState) go2(delta int) {
n := s.idx + delta
if n >= 0 && n < len(book.Chapters) {
s.SetState(func() { s.idx = n })
}
}
func (s *readerState) Build(ctx widget.Ctx) widget.Widget {
th := theme.Of(ctx)
nav := ctx.MustOf[widget.Nav]()
ch := book.Chapters[s.idx]
top := widget.Padding{Insets: geom.InsetsSymmetric(20, 14), Child: widget.Row(
widget.Interactive{Gestures: widget.Gestures{OnTap: nav.Pop}, Child: widget.Text{Value: "‹ Contents", Size: th.Type.Body, Color: th.Primary}},
widget.Spacer(),
widget.Text{Value: fmt.Sprintf("%d / %d", s.idx+1, len(book.Chapters)), Size: th.Type.Label, Color: th.Muted},
)}
var body []widget.Widget
for _, b := range ch.Blocks {
if b.Heading {
body = append(body, widget.Padding{Insets: geom.Insets{Top: 10, Bottom: 14}, Child: widget.Text{Value: b.Text, Font: theme.FontBold, Size: th.Type.Title, Color: th.Text, Wrap: true}})
} else {
body = append(body, widget.Padding{Insets: geom.Insets{Bottom: 18}, Child: widget.Text{Value: b.Text, Size: th.Type.Body, Color: th.Text, Wrap: true}})
}
}
// Key the scroll on the chapter so moving to another chapter starts at the top.
page := widget.WithKey{Key: s.idx, Child: widget.Scroll{Child: widget.Padding{
Insets: geom.Insets{Left: 28, Right: 28, Top: 8, Bottom: 40},
Child: widget.Flex{CrossAlign: layout.CrossStretch, Children: body},
}}}
bottom := widget.Padding{Insets: geom.InsetsSymmetric(20, 14), Child: widget.Row(
navBtn(th, "‹ Prev", s.idx > 0, func() { s.go2(-1) }),
widget.Spacer(),
navBtn(th, "Next ›", s.idx < len(book.Chapters)-1, func() { s.go2(1) }),
)}
return widget.Fill{Color: th.Bg, Child: widget.Flex{CrossAlign: layout.CrossStretch, Children: []widget.Widget{
top, divider(th), widget.Expand(page), divider(th), bottom,
}}}
}
func navBtn(th theme.Theme, label string, enabled bool, onTap func()) widget.Widget {
col := th.Muted
if enabled {
col = th.Primary
}
t := widget.Text{Value: label, Size: th.Type.Body, Color: col}
if !enabled {
return t
}
return widget.Interactive{Gestures: widget.Gestures{OnTap: onTap}, Child: t}
}
func divider(th theme.Theme) widget.Widget {
return widget.Sized{H: 1, Child: widget.Decorated{Color: th.Border}}
}
func main() {
if err := app.Run(App{}, app.Config{
Title: "Reader",
Size: geom.Size{W: 460, H: 720},
Background: BG,
Font: goregular.TTF,
FontFamilies: map[string][]byte{"bold": gobold.TTF},
}); err != nil {
log.Fatal(err)
}
}
epub.go
package main
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path"
"strings"
)
// Book is a parsed EPUB: metadata plus ordered chapters of text blocks.
type Book struct {
Title string
Author string
Chapters []Chapter
}
// Chapter is one spine document, reduced to its title + body blocks.
type Chapter struct {
Title string
Blocks []Block
}
// Block is a paragraph or a heading of already-flattened text.
type Block struct {
Heading bool
Text string
}
// --- the bundled sample book (authored here, so it's self-contained and has no
// copyright entanglements). Each chapter is real XHTML that the parser below
// extracts, so the demo exercises the whole epub pipeline. ---
type srcChapter struct{ file, xhtml string }
var sampleMeta = struct{ title, author string }{"The Lantern-Keeper's Almanac", "A. Marlowe"}
var sampleChapters = []srcChapter{
{"ch1.xhtml", `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>I</title></head><body>
<h2>I. The Tide-Clock</h2>
<p>The lighthouse kept two kinds of time. One was the plain time of the mainland,
which arrived each morning folded inside the mail-boat and was, by evening,
already wrong. The other was the tide-clock — a brass dial with no numbers,
only a slow hand that leaned toward the sea and away from it, patient as breath.</p>
<p>Elka had tended both since she was nine, when the old keeper handed her the
oil-can and said nothing at all, which she later understood to be the whole of
the instruction. You learned the light by living inside its turning.</p>
<p>On clear nights the beam swept the water like a long white thought, and the
gulls, caught in it, flared briefly into paper and were gone.</p>
</body></html>`},
{"ch2.xhtml", `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>II</title></head><body>
<h2>II. What the Almanac Said</h2>
<p>The almanac was not printed. It was kept — added to, argued with, occasionally
crossed out — by every keeper who had ever climbed the stair. Its pages
predicted nothing and remembered everything: the winter the herring failed, the
night three lamps were lit at once and no one afterward would say why.</p>
<p>Elka read it the way other people read weather: not for facts but for warning.
Near the back, in a hand browner than the rest, someone had written a single
line and underlined it twice. <em>When the sea keeps time, do not correct it.</em></p>
<p>She had corrected it once. She did not intend to make that mistake again.</p>
</body></html>`},
{"ch3.xhtml", `<?xml version="1.0" encoding="utf-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>III</title></head><body>
<h2>III. The Visitor Below the Waterline</h2>
<p>It came the way fog comes: not arriving so much as already having been there.
A knock, if a knock can be felt through stone and forty feet of cold water — one,
then two, then the long patient silence of something willing to wait.</p>
<p>Elka set down the oil-can. Above her the light turned, indifferent and exact,
throwing its slow white sentence out across the dark. Below her the tide-clock
leaned, and leaned, and did not come back.</p>
<p>She opened the almanac to a clean page, dipped the pen, and began — as every
keeper before her had begun — to write down precisely what she saw, so that the
next one to climb the stair would know it was not the first time.</p>
</body></html>`},
}
// loadBook builds the sample epub in memory and parses it back — the whole
// pipeline (zip → container.xml → OPF spine → XHTML). It panics on failure
// because the input is generated here and must always be valid.
func loadBook() *Book {
data := buildEPUB()
b, err := parseEPUB(data)
if err != nil {
panic("epub: " + err.Error())
}
return b
}
// buildEPUB assembles a spec-shaped .epub (stored mimetype first, META-INF
// container, OPF package, XHTML chapters) as an in-memory zip.
func buildEPUB() []byte {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
// The mimetype entry must be first and uncompressed (EPUB OCF rule).
mw, _ := zw.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
_, _ = io.WriteString(mw, "application/epub+zip")
write := func(name, body string) {
w, _ := zw.Create(name)
_, _ = io.WriteString(w, body)
}
write("META-INF/container.xml", `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`)
var manifest, spine strings.Builder
for i, c := range sampleChapters {
id := fmt.Sprintf("ch%d", i+1)
fmt.Fprintf(&manifest, ` <item id="%s" href="%s" media-type="application/xhtml+xml"/>`+"\n", id, c.file)
fmt.Fprintf(&spine, ` <itemref idref="%s"/>`+"\n", id)
write("OEBPS/"+c.file, c.xhtml)
}
write("OEBPS/content.opf", fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>%s</dc:title>
<dc:creator>%s</dc:creator>
<dc:language>en</dc:language>
</metadata>
<manifest>
%s </manifest>
<spine>
%s </spine>
</package>`, sampleMeta.title, sampleMeta.author, manifest.String(), spine.String()))
_ = zw.Close()
return buf.Bytes()
}
// parseEPUB reads an .epub: locate the OPF via META-INF/container.xml, read its
// metadata + spine order, then extract each spine document's text blocks.
func parseEPUB(data []byte) (*Book, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
files := map[string]*zip.File{}
for _, f := range zr.File {
files[f.Name] = f
}
// container.xml has the OPF path in the rootfile's full-path attribute.
var cont struct {
Rootfile struct {
FullPath string `xml:"full-path,attr"`
} `xml:"rootfiles>rootfile"`
}
if err := unmarshalZip(files, "META-INF/container.xml", &cont); err != nil {
return nil, err
}
opfPath := cont.Rootfile.FullPath
if opfPath == "" {
return nil, fmt.Errorf("no rootfile in container.xml")
}
var pkg struct {
Title string `xml:"metadata>title"`
Creator string `xml:"metadata>creator"`
Items []struct {
ID string `xml:"id,attr"`
Href string `xml:"href,attr"`
} `xml:"manifest>item"`
Spine []struct {
IDRef string `xml:"idref,attr"`
} `xml:"spine>itemref"`
}
if err := unmarshalZip(files, opfPath, &pkg); err != nil {
return nil, err
}
href := map[string]string{}
for _, it := range pkg.Items {
href[it.ID] = it.Href
}
base := path.Dir(opfPath)
book := &Book{Title: pkg.Title, Author: pkg.Creator}
for _, ref := range pkg.Spine {
rel, ok := href[ref.IDRef]
if !ok {
continue
}
raw, err := readZip(files, path.Join(base, rel))
if err != nil {
return nil, err
}
blocks := extractBlocks(raw)
ch := Chapter{Blocks: blocks}
if len(blocks) > 0 && blocks[0].Heading {
ch.Title = blocks[0].Text
} else {
ch.Title = fmt.Sprintf("Chapter %d", len(book.Chapters)+1)
}
book.Chapters = append(book.Chapters, ch)
}
return book, nil
}
// extractBlocks walks an XHTML document and reduces it to heading/paragraph
// blocks of flattened, whitespace-collapsed text. Inline markup (em/strong) is
// kept as plain text.
func extractBlocks(xhtml []byte) []Block {
dec := xml.NewDecoder(bytes.NewReader(xhtml))
var blocks []Block
var cur *Block
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
switch strings.ToLower(t.Name.Local) {
case "h1", "h2", "h3", "h4":
cur = &Block{Heading: true}
case "p":
cur = &Block{}
}
case xml.CharData:
if cur != nil {
cur.Text += string(t)
}
case xml.EndElement:
switch strings.ToLower(t.Name.Local) {
case "h1", "h2", "h3", "h4", "p":
if cur != nil {
if s := strings.Join(strings.Fields(cur.Text), " "); s != "" {
cur.Text = s
blocks = append(blocks, *cur)
}
cur = nil
}
}
}
}
return blocks
}
func readZip(files map[string]*zip.File, name string) ([]byte, error) {
f, ok := files[name]
if !ok {
return nil, fmt.Errorf("epub: missing %q", name)
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
func unmarshalZip(files map[string]*zip.File, name string, v any) error {
raw, err := readZip(files, name)
if err != nil {
return err
}
return xml.Unmarshal(raw, v)
}