// Package build packages an extracted article as an EPUB. package build import ( "bytes" _ "embed" "encoding/base64" "fmt" "html/template" "strings" "time" epub "github.com/go-shiori/go-epub" "github.com/google/uuid" "golang.org/x/net/html" "golang.org/x/net/html/atom" "git.monoexp.com/bajdev/rekopub/internal/assets" "git.monoexp.com/bajdev/rekopub/internal/clean" "git.monoexp.com/bajdev/rekopub/internal/extract" ) //go:embed chapter.tmpl var chapterTmpl string //go:embed style.css var styleCSS []byte var chapter = template.Must(template.New("chapter").Parse(chapterTmpl)) // DisplayDate formats a publication date as shown in the byline and on the // cover, or returns "" for the zero time. func DisplayDate(t time.Time) string { if t.IsZero() { return "" } return t.Format("2 January 2006") } // Build returns the EPUB for a, whose cleaned content is body (from // clean.Content) with images already processed into images. cover is a // JPEG cover image, or nil for none. func Build(a *extract.Article, body *html.Node, images []assets.Image, cover []byte) ([]byte, error) { // go-epub stages files in a temporary folder that it removes after each // write. Don't switch it to epub.MemoryFS: on Windows that loses every // file except mimetype, producing an empty book. e, err := epub.NewEpub(a.Title) if err != nil { return nil, err } // Derive the identifier from the URL, so rebuilding the same page gives // the same book identity and readers treat it as the same book. id := "urn:uuid:" + uuid.NewSHA1(uuid.NameSpaceURL, []byte(a.URL)).String() e.SetIdentifier(id) e.SetLang(a.Language) if a.Author != "" { e.SetAuthor(a.Author) } if a.Description != "" { e.SetDescription(a.Description) } css, err := e.AddCSS("data:text/css;base64,"+base64.StdEncoding.EncodeToString(styleCSS), "style.css") if err != nil { return nil, fmt.Errorf("adding stylesheet: %w", err) } if cover != nil { p, err := e.AddImage("data:image/jpeg;base64,"+base64.StdEncoding.EncodeToString(cover), "cover.jpg") if err != nil { return nil, fmt.Errorf("adding cover: %w", err) } if err := e.SetCover(p, ""); err != nil { return nil, fmt.Errorf("adding cover: %w", err) } } if err := addImages(e, body, images); err != nil { return nil, err } headings := anchorHeadings(body) chapter, err := chapterBody(a, body) if err != nil { return nil, err } if _, err := e.AddSection(chapter, a.Title, chapterFile, css); err != nil { return nil, err } var raw bytes.Buffer if _, err := e.WriteTo(&raw); err != nil { return nil, err } return finish(raw.Bytes(), id, a, headings) } // addImages stores each image in the book and points the elements, // whose src is the image's filename, at the stored file. func addImages(e *epub.Epub, body *html.Node, images []assets.Image) error { paths := map[string]string{} for _, img := range images { src := "data:" + img.MediaType + ";base64," + base64.StdEncoding.EncodeToString(img.Data) p, err := e.AddImage(src, img.Filename) if err != nil { return fmt.Errorf("adding image %s: %w", img.Filename, err) } paths[img.Filename] = p } // Only images processed by the assets package may be in the book; any // other would point at a remote resource, so it is removed. var unknown []*html.Node var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode && n.DataAtom == atom.Img { known := false for i, a := range n.Attr { if p, ok := paths[a.Val]; ok && a.Key == "src" { n.Attr[i].Val, known = p, true } } if !known { unknown = append(unknown, n) } } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(body) for _, n := range unknown { n.Parent.RemoveChild(n) } return nil } // chapterBody renders the chapter: a header with the title, byline and // source link, followed by the article content. func chapterBody(a *extract.Article, body *html.Node) (string, error) { content, err := clean.Render(body) if err != nil { return "", err } var b strings.Builder err = chapter.Execute(&b, struct { Lang, Title, Byline, URL string Content template.HTML }{ Lang: a.Language, Title: a.Title, Byline: byline(a), URL: a.URL, Content: template.HTML(content), // sanitized and checked by clean.Content }) if err != nil { return "", fmt.Errorf("rendering chapter: %w", err) } return b.String(), nil } // byline joins the author, site name and publication date, skipping any // that are unknown. func byline(a *extract.Article) string { var parts []string if a.Author != "" { parts = append(parts, a.Author) } if a.SiteName != "" { parts = append(parts, a.SiteName) } if d := DisplayDate(a.Published); d != "" { parts = append(parts, d) } return strings.Join(parts, " ยท ") }