Files
tiennm99 38c0b7a477 feat(pdf): export a novel as a phone-readable PDF
Add a pdfout package that renders the fetched chapters to a single PDF
and an export package holding the whole pipeline, so the CLI and an
embedding program share one code path and reject the same inputs.

The default page is 90x160mm rather than A4 with large type: a viewer
scales a whole page to fit the screen, so on a phone the page shape
decides legibility and a wide page just gets shrunk.

Embed DejaVu Sans in the binary. Vietnamese needs the Latin Extended
Additional block, and a headless host often has no fonts installed at
all, so rendering must not depend on finding one. An explicitly named
font is never silently substituted. Font data is passed as bytes because
fpdf resolves a font path against its own font directory.

Keep the original per-chapter text output available behind -txt.
2026-08-24 20:40:01 +07:00

278 lines
7.3 KiB
Go

// Package export turns a novel URL into a PDF file. It holds the sequence the
// CLI and any embedding program both need — read the chapter list, fetch the
// chapters, pick a font, render — so neither has to reassemble it.
package export
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/tiennm99/hako-crawler/hako"
"github.com/tiennm99/hako-crawler/pdfout"
)
// Defaults for every tunable field of Request. Exported so a caller's own flags
// or config can advertise the same values instead of restating them.
const (
DefaultPage = "phone"
DefaultFontSize = 10.0
DefaultLineSpacing = 1.55
DefaultMargin = 6.0
DefaultWorkers = 4
DefaultDelay = 500 * time.Millisecond
DefaultRetries = 3
DefaultCacheDir = ".cache"
)
// Request describes one export. Only NovelURL is required; every zero-valued
// tunable falls back to its Default above, so a caller that only has a URL can
// leave the rest alone.
type Request struct {
NovelURL string
// OutPath is the exact PDF path to write. When empty the file is named
// after the novel and placed in OutDir.
OutPath string
OutDir string
// TextDir, when set, also writes one plain-text file per chapter — the
// output this project produced before it rendered PDFs.
TextDir string
Page string // preset key: phone, a5, a4
FontFile string // path to a .ttf; empty means discover a system font
FontSize float64 // points
LineSpacing float64 // multiple of font size
Margin float64 // millimetres
Workers int
Retries int
// Delay is the minimum spacing between requests. Set NoDelay to remove the
// spacing rather than setting this to 0, which is read as "unset" and gets
// the default back.
Delay time.Duration
NoDelay bool
// Limit caps the export to the first N chapters. 0 means every chapter.
Limit int
// CacheDir stores raw pages so a re-export costs no requests. Set NoCache
// to opt out rather than clearing this field, which would be read as
// "unset" and get the default back.
CacheDir string
NoCache bool
// Log receives progress messages. Optional.
Log func(format string, args ...any)
}
// Result reports what was produced.
type Result struct {
Path string
TextDir string
Title string
SourceURL string
Chapters int
Words int
FontName string // font path, or pdfout.BundledFontName
Page pdfout.PageSize
}
// Summary renders a one-line description of the exported book.
func (r *Result) Summary() string {
return fmt.Sprintf("%s — %d chapters, %d words", r.Title, r.Chapters, r.Words)
}
// Export fetches the novel at req.NovelURL and writes it as a PDF, returning
// where it landed. The context bounds the whole crawl; cancelling it abandons
// the run without leaving a partial PDF behind.
func Export(ctx context.Context, req Request) (*Result, error) {
req.applyDefaults()
if err := req.validate(); err != nil {
return nil, err
}
crawler := &hako.Crawler{
Client: hako.NewClient(req.Delay, req.Retries),
CacheDir: req.CacheDir,
Workers: req.Workers,
Log: req.Log,
}
novel, err := crawler.Novel(ctx, req.NovelURL)
if err != nil {
return nil, err
}
if req.Limit > 0 && req.Limit < len(novel.Chapters) {
req.logf("limiting to first %d of %d chapters", req.Limit, len(novel.Chapters))
novel.Chapters = novel.Chapters[:req.Limit]
}
chapters, err := crawler.Chapters(ctx, novel)
if err != nil {
return nil, err
}
// Falls back to the bundled font, so a host with no fonts installed still
// renders; only an explicitly requested font can fail here.
font, err := pdfout.LoadFont(req.FontFile)
if err != nil {
return nil, err
}
outPath := req.OutPath
if outPath == "" {
outPath = filepath.Join(req.OutDir, SafeFileName(novel.Title, novel.Slug)+".pdf")
}
page := pdfout.Presets[req.Page]
opts := pdfout.Options{
Page: page,
Margin: req.Margin,
Font: font,
FontSize: req.FontSize,
LineSpacing: req.LineSpacing,
Title: novel.Title,
SourceURL: novel.URL,
}
if err := pdfout.Write(outPath, opts, toPDFChapters(chapters)); err != nil {
return nil, err
}
if req.TextDir != "" {
if err := writeText(req.TextDir, chapters); err != nil {
return nil, err
}
}
return &Result{
Path: outPath,
TextDir: req.TextDir,
Title: novel.Title,
SourceURL: novel.URL,
Chapters: len(chapters),
Words: hako.TotalWords(chapters),
FontName: font.Name,
Page: page,
}, nil
}
func (r *Request) applyDefaults() {
if r.Page == "" {
r.Page = DefaultPage
}
if r.FontSize == 0 {
r.FontSize = DefaultFontSize
}
if r.LineSpacing == 0 {
r.LineSpacing = DefaultLineSpacing
}
if r.Margin == 0 {
r.Margin = DefaultMargin
}
if r.Workers == 0 {
r.Workers = DefaultWorkers
}
switch {
case r.NoDelay:
r.Delay = 0
case r.Delay == 0:
r.Delay = DefaultDelay
}
if r.Retries == 0 {
r.Retries = DefaultRetries
}
switch {
case r.NoCache:
r.CacheDir = ""
case r.CacheDir == "":
r.CacheDir = DefaultCacheDir
}
}
// validate rejects a Request before any request is made, so a typo costs no
// fetches. Call applyDefaults first: it checks the effective values.
func (r *Request) validate() error {
if r.NovelURL == "" {
return fmt.Errorf("novel url is required")
}
parsed, err := url.Parse(r.NovelURL)
if err != nil {
return fmt.Errorf("invalid novel url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("invalid novel url: want an http(s) URL, got %q", r.NovelURL)
}
if _, ok := pdfout.Presets[r.Page]; !ok {
return fmt.Errorf("unknown page %q: want one of %s",
r.Page, strings.Join(pdfout.PresetNames(), ", "))
}
if r.FontSize <= 0 {
return fmt.Errorf("font size must be positive")
}
if r.LineSpacing <= 0 {
return fmt.Errorf("line spacing must be positive")
}
if r.Margin < 0 {
return fmt.Errorf("margin cannot be negative")
}
if r.Workers < 1 {
return fmt.Errorf("workers must be at least 1")
}
return nil
}
func (r *Request) logf(format string, args ...any) {
if r.Log != nil {
r.Log(format, args...)
}
}
func toPDFChapters(chapters []*hako.Chapter) []pdfout.Chapter {
out := make([]pdfout.Chapter, 0, len(chapters))
for _, ch := range chapters {
out = append(out, pdfout.Chapter{
Heading: ch.Heading(),
Volume: ch.Volume,
Paragraphs: ch.Paragraphs,
})
}
return out
}
// writeText saves one file per chapter, numbered so a directory listing stays
// in reading order rather than in the site's own chapter naming.
func writeText(dir string, chapters []*hako.Chapter) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create text dir: %w", err)
}
for i, ch := range chapters {
name := fmt.Sprintf("%04d-%s.txt", i+1, SafeFileName(ch.Heading(), "chuong"))
body := ch.Heading() + "\n\n" + strings.Join(ch.Paragraphs, "\n\n") + "\n"
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
}
return nil
}
var unsafeNameChars = regexp.MustCompile(`[^\p{L}\p{N}]+`)
// SafeFileName builds a file name from a title, falling back when the title has
// no usable characters. The result has no extension.
func SafeFileName(title, fallback string) string {
name := strings.Trim(unsafeNameChars.ReplaceAllString(title, "-"), "-")
if name == "" {
return fallback
}
return name
}