Files
tiennm99 d17a79445f 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

164 lines
4.8 KiB
Go

package pdfout
import (
"fmt"
"sort"
"strings"
"github.com/go-pdf/fpdf"
)
// pointsToMM converts typographic points to millimetres.
const pointsToMM = 25.4 / 72.0
// bodyFont is the internal family name registered with the PDF.
const bodyFont = "body"
// PageSize is a page in millimetres.
type PageSize struct {
Name string
W, H float64
}
// Presets are the selectable page geometries.
//
// Phone reading depends far more on page shape than on font size. A viewer
// scales a whole page to fit the screen, so a large font on an A4 page still
// ends up tiny: the page is ~3x wider than the screen and gets shrunk to match.
// A page cut to the phone's own aspect ratio fills the screen at 100%, which is
// why "phone" is a small 9:16 page rather than A4 with big type.
var Presets = map[string]PageSize{
"phone": {"phone", 90, 160},
"a5": {"a5", 148, 210},
"a4": {"a4", 210, 297},
}
// PresetNames lists preset keys in a stable order for help text.
func PresetNames() []string {
names := make([]string, 0, len(Presets))
for name := range Presets {
names = append(names, name)
}
sort.Strings(names)
return names
}
// Chapter is a chapter ready to render.
type Chapter struct {
Heading string
// Volume labels the section the chapter belongs to. It is printed above
// the heading only when it differs from the previous chapter's, which is
// what keeps a volume-structured novel navigable without a separate page
// per volume.
Volume string
Paragraphs []string
}
// Options controls the exported PDF.
type Options struct {
Page PageSize
Margin float64 // mm
Font Font // resolve with LoadFont
FontSize float64 // pt
LineSpacing float64 // multiple of font size
Title string
SourceURL string
}
// footerReserve is the vertical space kept clear for the page number.
const footerReserve = 6.0
// Write renders the chapters to a PDF at path.
func Write(path string, opts Options, chapters []Chapter) error {
pdf := fpdf.NewCustom(&fpdf.InitType{
UnitStr: "mm",
Size: fpdf.SizeType{Wd: opts.Page.W, Ht: opts.Page.H},
})
pdf.SetMargins(opts.Margin, opts.Margin, opts.Margin)
pdf.SetAutoPageBreak(true, opts.Margin+footerReserve)
// Embeds a subset of the TrueType data, which is what makes the Vietnamese
// diacritics render instead of falling back to "?". The bytes are passed
// directly rather than by path: the path-taking variant joins the name onto
// fpdf's own font directory (default "."), which mangles an absolute path
// into a working-directory-relative one.
pdf.AddUTF8FontFromBytes(bodyFont, "", opts.Font.Data)
pdf.SetFont(bodyFont, "", opts.FontSize)
pdf.SetTitle(opts.Title, true)
lineHeight := opts.FontSize * opts.LineSpacing * pointsToMM
paragraphGap := lineHeight * 0.45
headingSize := opts.FontSize * 1.35
addFooter(pdf, opts)
writeTitlePage(pdf, opts, len(chapters))
volume := ""
for _, ch := range chapters {
pdf.AddPage()
if ch.Volume != "" && ch.Volume != volume {
pdf.SetFontSize(opts.FontSize * 0.8)
pdf.SetTextColor(120, 120, 120)
pdf.MultiCell(0, opts.FontSize*1.2*pointsToMM, strings.ToUpper(ch.Volume), "", "L", false)
pdf.SetTextColor(0, 0, 0)
pdf.Ln(paragraphGap)
}
volume = ch.Volume
pdf.SetFontSize(headingSize)
pdf.MultiCell(0, headingSize*1.3*pointsToMM, ch.Heading, "", "L", false)
pdf.Ln(paragraphGap * 1.6)
pdf.SetFontSize(opts.FontSize)
for _, p := range ch.Paragraphs {
// "J" justifies, which keeps the short measure of a phone page tidy.
pdf.MultiCell(0, lineHeight, p, "", "J", false)
pdf.Ln(paragraphGap)
}
}
if err := pdf.OutputFileAndClose(path); err != nil {
return fmt.Errorf("write pdf %s: %w", path, err)
}
return nil
}
// addFooter prints a centred page number, restoring the body font size so the
// footer callback cannot leak its own size into the following content.
func addFooter(pdf *fpdf.Fpdf, opts Options) {
pdf.SetFooterFunc(func() {
if pdf.PageNo() <= 1 {
return
}
pdf.SetY(-(opts.Margin + footerReserve*0.6))
pdf.SetFontSize(opts.FontSize * 0.75)
pdf.SetTextColor(120, 120, 120)
pdf.CellFormat(0, 4, fmt.Sprintf("%d", pdf.PageNo()-1), "", 0, "C", false, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.SetFontSize(opts.FontSize)
})
}
func writeTitlePage(pdf *fpdf.Fpdf, opts Options, chapterCount int) {
pdf.AddPage()
pdf.SetY(opts.Page.H * 0.30)
titleSize := opts.FontSize * 1.9
pdf.SetFontSize(titleSize)
pdf.MultiCell(0, titleSize*1.35*pointsToMM, strings.ToUpper(opts.Title), "", "C", false)
pdf.Ln(opts.FontSize * pointsToMM * 2)
pdf.SetFontSize(opts.FontSize * 0.85)
pdf.SetTextColor(90, 90, 90)
pdf.MultiCell(0, opts.FontSize*1.4*pointsToMM,
fmt.Sprintf("%d chương", chapterCount), "", "C", false)
if opts.SourceURL != "" {
pdf.MultiCell(0, opts.FontSize*1.4*pointsToMM, opts.SourceURL, "", "C", false)
}
pdf.SetTextColor(0, 0, 0)
pdf.SetFontSize(opts.FontSize)
}