mirror of
https://github.com/tiennm99/monkeyd-crawler.git
synced 2026-08-07 01:21:09 +00:00
PDF rendering failed on Linux with "stat usr/share/fonts/...: no such file or directory": fpdf joins the font path onto its own font directory, which it defaults to ".", so path.Join turns an absolute path into a working-directory-relative one. It only resolved when the process happened to run from the filesystem root, which is why Windows was unaffected. Font data is now read by pdfout and handed over as bytes. LoadFont also falls back to a bundled DejaVu Sans, so a host with no fonts installed still renders. An explicitly requested font remains a hard error when unreadable rather than being silently substituted. Tests verify the bundled font parses and covers Vietnamese, which is the reason it exists.
148 lines
4.3 KiB
Go
148 lines
4.3 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 phone
|
|
// 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
|
|
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))
|
|
|
|
for _, ch := range chapters {
|
|
pdf.AddPage()
|
|
|
|
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)
|
|
}
|