mirror of
https://github.com/tiennm99/MTTools.git
synced 2026-10-15 14:16:35 +00:00
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:
1 parent
2855ce5e53
commit
3633e96cf5
15 files changed
+490
-128
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user