refactor: make crawler importable and add export pipeline

Move monkeyd and pdfout out of internal/ so other modules can import them,
and add export.Export, which holds the crawl-to-PDF sequence the CLI used to
inline. The CLI now parses flags and delegates, so an embedding program gets
the same defaults and validation.
This commit is contained in:
tiennm99 committed 2026-07-29 22:49:30 +07:00
1 parent 2855ce5e53
commit 3633e96cf5
15 files changed
+490 -128

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
package pdfout
import (
"fmt"
"os"
"path/filepath"
"runtime"
)
// Vietnamese text needs the Latin Extended Additional block (ư, ạ, ế, ộ …).
// Every font listed here ships with its platform and covers it; fonts with only
// basic Latin would silently drop the diacritics.
func fontCandidates() []string {
switch runtime.GOOS {
case "windows":
dir := filepath.Join(os.Getenv("SystemRoot"), "Fonts")
if os.Getenv("SystemRoot") == "" {
dir = `C:\Windows\Fonts`
}
return []string{
filepath.Join(dir, "segoeui.ttf"),
filepath.Join(dir, "arial.ttf"),
filepath.Join(dir, "calibri.ttf"),
filepath.Join(dir, "tahoma.ttf"),
filepath.Join(dir, "times.ttf"),
}
case "darwin":
return []string{
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Supplemental/Times New Roman.ttf",
}
default:
return []string{
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
}
}
}
// FindFont returns the first available system font suitable for Vietnamese.
func FindFont() (string, error) {
candidates := fontCandidates()
for _, path := range candidates {
if info, err := os.Stat(path); err == nil && !info.IsDir() {
return path, nil
}
}
return "", fmt.Errorf("no Vietnamese-capable system font found (looked for %v); "+
"pass -font with a path to a .ttf file", candidates)
}
+144
View File
@@ -0,0 +1,144 @@
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
FontFile string
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)
// AddUTF8Font embeds a subset of the TrueType file, which is what makes the
// Vietnamese diacritics render instead of falling back to "?".
pdf.AddUTF8Font(bodyFont, "", opts.FontFile)
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)
}
+96
View File
@@ -0,0 +1,96 @@
package pdfout
import (
"os"
"path/filepath"
"testing"
)
func testOptions(t *testing.T) Options {
t.Helper()
font, err := FindFont()
if err != nil {
t.Skipf("no system font available: %v", err)
}
return Options{
Page: Presets["phone"],
Margin: 6,
FontFile: font,
FontSize: 12,
LineSpacing: 1.55,
Title: "TRỞ LẠI NĂM THÁNG CŨ",
SourceURL: "https://example.test/n.html",
}
}
func TestWriteProducesReadablePDF(t *testing.T) {
path := filepath.Join(t.TempDir(), "out.pdf")
chapters := []Chapter{
{Heading: "Chương 1", Paragraphs: []string{
"Nghe vị trưởng tử nói chuyện với nàng.",
"Một đoạn văn khác để kiểm tra ngắt dòng tự động trên trang nhỏ.",
}},
{Heading: "Chương 2", Paragraphs: []string{"Đoạn cuối."}},
}
if err := Write(path, testOptions(t), chapters); err != nil {
t.Fatalf("Write: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat output: %v", err)
}
if info.Size() == 0 {
t.Fatal("wrote an empty PDF")
}
header := make([]byte, 5)
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := f.Read(header); err != nil {
t.Fatal(err)
}
if string(header) != "%PDF-" {
t.Errorf("output does not start with a PDF header, got %q", header)
}
}
// A novel with many chapters must not overflow a page; auto page break plus the
// footer reserve handles that, so a long chapter should span several pages.
func TestWriteHandlesLongChapters(t *testing.T) {
path := filepath.Join(t.TempDir(), "long.pdf")
paragraphs := make([]string, 200)
for i := range paragraphs {
paragraphs[i] = "Một đoạn văn dài để buộc trình kết xuất phải sang trang mới nhiều lần."
}
if err := Write(path, testOptions(t), []Chapter{{Heading: "Chương 1", Paragraphs: paragraphs}}); err != nil {
t.Fatalf("Write: %v", err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Size() < 2000 {
t.Errorf("output suspiciously small (%d bytes) for 200 paragraphs", info.Size())
}
}
func TestPresetNamesIsStable(t *testing.T) {
got := PresetNames()
want := []string{"a4", "a5", "phone"}
if len(got) != len(want) {
t.Fatalf("PresetNames() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("PresetNames()[%d] = %q, want %q", i, got[i], want[i])
}
}
}