mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-17 10:15:09 +00:00
refactor(monkeyd): port the crawler in-tree and drop the submodule
The monkeyd-crawler repository moved into tiennm99/mttools, so recursive clones of the old submodule URL fail and block every deploy. The crawler, PDF renderer, and export flow now live under internal/modules/monkeyd, trimmed to the bot's use: a fixed phone page, the bundled font only, and the font size as the single export option.
This commit is contained in:
1 parent
c1393f0ec8
commit
d211124aae
26 files changed
+1818
-30
No files matched your search
@@ -0,0 +1,37 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
// bundledTTF is the only font the bot renders with. A minimal container has no
|
||||
// system fonts, and a single embedded font keeps the PDF identical on every
|
||||
// host. See fonts/NOTICE.md for provenance and licensing.
|
||||
//
|
||||
// Vietnamese text needs the Latin Extended Additional block (ư, ạ, ế, ộ …);
|
||||
// DejaVu Sans covers it, where a basic-Latin font would silently drop the
|
||||
// diacritics.
|
||||
//
|
||||
//go:embed fonts/DejaVuSans.ttf
|
||||
var bundledTTF []byte
|
||||
|
||||
// BundledFontName labels the embedded font in diagnostics. It is not a path;
|
||||
// the font is compiled into the binary.
|
||||
const BundledFontName = "DejaVu Sans (bundled)"
|
||||
|
||||
// Font is font data ready to embed in a PDF.
|
||||
//
|
||||
// The data is carried as bytes rather than as a path because fpdf joins a font
|
||||
// path onto its own font directory, which it defaults to "." — turning an
|
||||
// absolute path into a working-directory-relative one that resolves only when
|
||||
// the process happens to run from the filesystem root.
|
||||
type Font struct {
|
||||
// Name identifies the font for diagnostics.
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// BundledFont returns the font compiled into the binary.
|
||||
func BundledFont() Font {
|
||||
return Font{Name: BundledFontName, Data: bundledTTF}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/image/font/sfnt"
|
||||
)
|
||||
|
||||
func TestBundledFontIsParseable(t *testing.T) {
|
||||
font := BundledFont()
|
||||
if font.Name != BundledFontName {
|
||||
t.Errorf("Name = %q, want %q", font.Name, BundledFontName)
|
||||
}
|
||||
if _, err := sfnt.Parse(font.Data); err != nil {
|
||||
t.Fatalf("bundled font does not parse as a TrueType font: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The bundled font exists to render Vietnamese. A replacement that lacks these
|
||||
// glyphs would silently emit blanks, so check before trusting it.
|
||||
func TestBundledFontCoversVietnamese(t *testing.T) {
|
||||
parsed, err := sfnt.Parse(BundledFont().Data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse bundled font: %v", err)
|
||||
}
|
||||
var buf sfnt.Buffer
|
||||
for _, r := range []rune{'ư', 'ơ', 'đ', 'ạ', 'ế', 'ộ', 'ữ', 'ằ', 'ỷ', 'ỹ', 'Ọ', 'Ế', 'Ư', 'Đ'} {
|
||||
index, err := parsed.GlyphIndex(&buf, r)
|
||||
if err != nil {
|
||||
t.Errorf("GlyphIndex(%q): %v", r, err)
|
||||
continue
|
||||
}
|
||||
// Glyph 0 is .notdef — the character is absent from the font.
|
||||
if index == 0 {
|
||||
t.Errorf("bundled font has no glyph for %q (U+%04X)", r, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
# Bundled font
|
||||
|
||||
`DejaVuSans.ttf` is embedded into the binary and used when no font is supplied
|
||||
and no suitable system font is found. It covers the Latin Extended Additional
|
||||
block, which is what Vietnamese diacritics need — a font with only basic Latin
|
||||
coverage silently drops them.
|
||||
|
||||
The following is recorded in the font file's own name table:
|
||||
|
||||
- Version: `Version 2.37`
|
||||
- Copyright: `Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.`
|
||||
`Copyright (c) 2006 by Tavmjong Bah. All Rights Reserved.`
|
||||
`DejaVu changes are in public domain`
|
||||
- License information: <http://dejavu.sourceforge.net/wiki/index.php/License>
|
||||
|
||||
The DejaVu fonts are free and redistributable, which is why they ship with most
|
||||
Linux distributions. This copy came from Alpine's `font-dejavu` package, which
|
||||
does not include the license text as a separate file. For a vendored copy of the
|
||||
full license text, take `LICENSE` from the upstream DejaVu release and add it to
|
||||
this directory.
|
||||
|
||||
To swap the bundled font, replace `DejaVuSans.ttf` and update this file. Verify
|
||||
the replacement covers Vietnamese first — `TestBundledFontCoversVietnamese` in
|
||||
`../font_test.go` checks a representative set of characters.
|
||||
@@ -0,0 +1,132 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
// PhonePage is the page every export uses.
|
||||
//
|
||||
// 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 this is a small 9:16 page rather than A4 with big type.
|
||||
var PhonePage = PageSize{Name: "phone", W: 90, H: 160}
|
||||
|
||||
// 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 // BundledFont()
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package pdf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testOptions(t *testing.T) Options {
|
||||
t.Helper()
|
||||
return Options{
|
||||
Page: PhonePage,
|
||||
Margin: 6,
|
||||
Font: BundledFont(),
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user