Files
agent/backend/internal/logic/documents/service.go

800 lines
25 KiB
Go

package documents
import (
"archive/zip"
"bytes"
"crypto/rand"
"crypto/sha256"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"html"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"github.com/eidrisov/xlsReader/xls"
"github.com/signintech/gopdf"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"senlinai-agent/backend/internal/models"
)
var (
ErrInvalidDocument = errors.New("invalid document")
ErrInvalidParent = errors.New("invalid document parent")
ErrNameConflict = errors.New("document name already exists")
ErrRevisionConflict = errors.New("document revision conflict")
ErrUnsupportedExport = errors.New("unsupported document export")
ErrShareNotFound = errors.New("document share not found")
ErrBlobNotFound = errors.New("document blob not found")
)
type Service struct {
db *gorm.DB
root string
now func() time.Time
}
type CreateInput struct {
Name string
ParentIdentity string
Markdown string
SourceInboxItemID *uint
}
type UpdateInput struct {
Name *string
ParentIdentity *string
Markdown *string
Revision *uint64
}
type StoredBlob struct {
StorageKey string
RelativePath string
AbsolutePath string
OriginalName string
MimeType string
Extension string
Size int64
Checksum string
}
type SharedDocument struct {
Document models.SaDocumentTree
Content *models.SaDocumentContent
Blob *models.SaDocumentBlob
}
type SheetPreview struct {
SheetName string `json:"sheetName"`
Rows [][]string `json:"rows"`
Truncated bool `json:"truncated"`
}
//go:embed assets/NotoSansSC.ttf
var documentPDFFont []byte
func NewService(database *gorm.DB, root string) *Service {
return &Service{db: database, root: root, now: time.Now}
}
func (s *Service) database() *gorm.DB {
if s.db != nil {
return s.db
}
return models.DBService
}
func (s *Service) List(userID uint, projectIdentity string) ([]models.SaDocumentTree, error) {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return nil, err
}
var documents []models.SaDocumentTree
err = s.database().Where("project_id = ?", project.ID).
Order("kind asc, normalized_name asc, id asc").Find(&documents).Error
return documents, err
}
func (s *Service) CreateFolder(userID uint, projectIdentity string, input CreateInput) (*models.SaDocumentTree, error) {
return s.createNode(userID, projectIdentity, input, models.DocumentKindFolder, "", "", 0)
}
func (s *Service) CreateMarkdown(userID uint, projectIdentity string, input CreateInput) (*models.SaDocumentTree, error) {
if strings.TrimSpace(filepath.Ext(input.Name)) == "" {
input.Name += ".md"
}
var created *models.SaDocumentTree
err := s.database().Transaction(func(tx *gorm.DB) error {
service := NewService(tx, s.root)
document, err := service.createNode(userID, projectIdentity, input, models.DocumentKindFile, ".md", "text/markdown; charset=utf-8", int64(len([]byte(input.Markdown))))
if err != nil {
return err
}
if err := tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: input.Markdown}).Error; err != nil {
return err
}
created = document
return nil
})
return created, err
}
func (s *Service) createNode(userID uint, projectIdentity string, input CreateInput, kind, extension, mimeType string, size int64) (*models.SaDocumentTree, error) {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return nil, err
}
parentID, parentIdentity, err := s.resolveParent(project.ID, input.ParentIdentity)
if err != nil {
return nil, err
}
name := cleanDisplayName(input.Name)
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
return nil, ErrInvalidDocument
}
name, err = s.availableName(project.ID, parentID, name)
if err != nil {
return nil, err
}
if kind == models.DocumentKindFile {
extension = strings.ToLower(filepath.Ext(name))
}
document := &models.SaDocumentTree{
ProjectID: project.ID, ProjectIdentity: project.Identity,
ParentID: parentID, ParentIdentity: parentIdentity,
ParentScope: identityValue(parentIdentity),
OwnerID: userID, SourceInboxItemID: input.SourceInboxItemID,
Kind: kind, Name: name, NormalizedName: normalizeName(name),
Extension: extension, MimeType: mimeType, Size: size, Revision: 1,
ScanStatus: "not_scanned",
}
if err := s.database().Create(document).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, ErrNameConflict
}
return nil, err
}
return document, nil
}
func (s *Service) CreateUploaded(userID uint, projectIdentity, parentIdentity string, stored StoredBlob) (*models.SaDocumentTree, error) {
var created *models.SaDocumentTree
err := s.database().Transaction(func(tx *gorm.DB) error {
service := NewService(tx, s.root)
document, err := service.createNode(userID, projectIdentity, CreateInput{
Name: stored.OriginalName, ParentIdentity: parentIdentity,
}, models.DocumentKindFile, stored.Extension, stored.MimeType, stored.Size)
if err != nil {
return err
}
if err := tx.Create(&models.SaDocumentBlob{
DocumentID: document.ID, StorageKey: stored.StorageKey, RelativePath: stored.RelativePath,
OriginalName: stored.OriginalName, Checksum: stored.Checksum,
}).Error; err != nil {
return err
}
created = document
return nil
})
if err != nil {
_ = os.Remove(stored.AbsolutePath)
}
return created, err
}
func (s *Service) Get(userID uint, projectIdentity, documentIdentity string) (*SharedDocument, error) {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return nil, err
}
var document models.SaDocumentTree
if err := s.database().Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID).First(&document).Error; err != nil {
return nil, err
}
return s.loadDocument(document)
}
func (s *Service) Update(userID uint, projectIdentity, documentIdentity string, input UpdateInput) (*SharedDocument, error) {
var updated *SharedDocument
err := s.database().Transaction(func(tx *gorm.DB) error {
service := NewService(tx, s.root)
project, err := service.ownedProject(userID, projectIdentity)
if err != nil {
return err
}
var document models.SaDocumentTree
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID).
First(&document).Error; err != nil {
return err
}
updates := map[string]any{}
if input.Name != nil {
name := cleanDisplayName(*input.Name)
if name == "" || strings.ContainsAny(name, `/\`) {
return ErrInvalidDocument
}
if normalizeName(name) != document.NormalizedName {
if service.nameExists(project.ID, document.ParentID, normalizeName(name), document.ID) {
return ErrNameConflict
}
updates["name"] = name
updates["normalized_name"] = normalizeName(name)
if document.Kind == models.DocumentKindFile {
updates["extension"] = strings.ToLower(filepath.Ext(name))
}
}
}
if input.ParentIdentity != nil {
parentID, parentIdentity, err := service.resolveParent(project.ID, *input.ParentIdentity)
if err != nil {
return err
}
if parentID != nil && *parentID == document.ID {
return ErrInvalidParent
}
if document.Kind == models.DocumentKindFolder && parentID != nil {
descendant, err := service.isDescendant(document.ID, *parentID)
if err != nil {
return err
}
if descendant {
return ErrInvalidParent
}
}
nextName := document.NormalizedName
if value, ok := updates["normalized_name"].(string); ok {
nextName = value
}
if service.nameExists(project.ID, parentID, nextName, document.ID) {
return ErrNameConflict
}
updates["parent_id"] = parentID
updates["parent_identity"] = parentIdentity
updates["parent_scope"] = identityValue(parentIdentity)
}
if input.Markdown != nil {
if document.Kind != models.DocumentKindFile || document.Extension != ".md" || input.Revision == nil {
return ErrInvalidDocument
}
if document.Revision != *input.Revision {
return ErrRevisionConflict
}
var content models.SaDocumentContent
err := tx.Where("document_id = ?", document.ID).First(&content).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
content = models.SaDocumentContent{DocumentID: document.ID, Markdown: *input.Markdown}
err = tx.Create(&content).Error
} else if err == nil {
err = tx.Model(&content).Update("markdown", *input.Markdown).Error
}
if err != nil {
return err
}
updates["size"] = int64(len([]byte(*input.Markdown)))
updates["revision"] = document.Revision + 1
}
if len(updates) > 0 {
if err := tx.Model(&document).Updates(updates).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ErrNameConflict
}
return err
}
}
if err := tx.Where("id = ?", document.ID).First(&document).Error; err != nil {
return err
}
updated, err = service.loadDocument(document)
return err
})
return updated, err
}
func (s *Service) Delete(userID uint, projectIdentity, documentIdentity string) error {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return err
}
var document models.SaDocumentTree
if err := s.database().Where("identity = ? AND project_id = ?", documentIdentity, project.ID).First(&document).Error; err != nil {
return err
}
ids, err := s.subtreeIDs(project.ID, document.ID)
if err != nil {
return err
}
return s.database().Where("id IN ?", ids).Delete(&models.SaDocumentTree{}).Error
}
func (s *Service) Store(projectIdentity, originalName string, source io.Reader) (StoredBlob, error) {
projectSegment := filepath.Base(strings.TrimSpace(projectIdentity))
if projectSegment == "." || projectSegment == "" || projectSegment != strings.TrimSpace(projectIdentity) {
return StoredBlob{}, ErrInvalidDocument
}
name := cleanDisplayName(originalName)
if name == "" {
name = "upload.bin"
}
directoryRelative := filepath.ToSlash(filepath.Join("projects", projectSegment, "documents"))
directoryAbsolute, err := s.absolutePath(directoryRelative)
if err != nil {
return StoredBlob{}, err
}
if err := os.MkdirAll(directoryAbsolute, 0o755); err != nil {
return StoredBlob{}, err
}
temp, err := os.CreateTemp(directoryAbsolute, ".upload-*")
if err != nil {
return StoredBlob{}, err
}
tempName := temp.Name()
defer func() { _ = os.Remove(tempName) }()
hasher := sha256.New()
var sniff bytes.Buffer
writer := io.MultiWriter(temp, hasher, &limitedWriter{writer: &sniff, remaining: 512})
size, copyErr := io.Copy(writer, source)
closeErr := temp.Close()
if copyErr != nil {
return StoredBlob{}, copyErr
}
if closeErr != nil {
return StoredBlob{}, closeErr
}
keyBytes := make([]byte, 16)
if _, err := rand.Read(keyBytes); err != nil {
return StoredBlob{}, err
}
key := hex.EncodeToString(keyBytes)
relative := filepath.ToSlash(filepath.Join(directoryRelative, key))
absolute, err := s.absolutePath(relative)
if err != nil {
return StoredBlob{}, err
}
if err := os.Rename(tempName, absolute); err != nil {
return StoredBlob{}, err
}
detected := http.DetectContentType(sniff.Bytes())
extension := strings.ToLower(filepath.Ext(name))
if extensionType := mime.TypeByExtension(extension); extensionType != "" && detected == "application/octet-stream" {
detected = extensionType
}
return StoredBlob{
StorageKey: key, RelativePath: relative, AbsolutePath: absolute, OriginalName: name,
MimeType: detected, Extension: extension, Size: size, Checksum: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
func (s *Service) OpenBlob(blob models.SaDocumentBlob) (*os.File, error) {
absolute, err := s.absolutePath(blob.RelativePath)
if err != nil {
return nil, err
}
return os.Open(absolute)
}
func (s *Service) PreviewLegacySheet(loaded SharedDocument, maxRows, maxColumns int) (SheetPreview, error) {
if loaded.Document.Extension != ".xls" || loaded.Blob == nil {
return SheetPreview{}, ErrInvalidDocument
}
file, err := s.OpenBlob(*loaded.Blob)
if err != nil {
return SheetPreview{}, err
}
defer file.Close()
workbook, err := xls.OpenReader(file)
if err != nil {
return SheetPreview{}, ErrInvalidDocument
}
sheet, err := workbook.GetSheet(0)
if err != nil {
return SheetPreview{}, ErrInvalidDocument
}
sourceRows := sheet.GetRows()
preview := SheetPreview{SheetName: sheet.GetName(), Truncated: len(sourceRows) > maxRows}
if len(sourceRows) > maxRows {
sourceRows = sourceRows[:maxRows]
}
preview.Rows = make([][]string, 0, len(sourceRows))
for _, sourceRow := range sourceRows {
columns := sourceRow.GetCols()
if len(columns) > maxColumns {
columns = columns[:maxColumns]
preview.Truncated = true
}
row := make([]string, 0, len(columns))
for _, column := range columns {
row = append(row, column.GetString())
}
preview.Rows = append(preview.Rows, row)
}
return preview, nil
}
func (s *Service) CreateShare(userID uint, projectIdentity, documentIdentity string, duration time.Duration) (*models.SaDocumentShare, string, error) {
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
if err != nil {
return nil, "", err
}
if loaded.Document.Kind != models.DocumentKindFile {
return nil, "", ErrInvalidDocument
}
if duration <= 0 || duration > 30*24*time.Hour {
return nil, "", ErrInvalidDocument
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, "", err
}
token := hex.EncodeToString(tokenBytes)
hash := sha256.Sum256([]byte(token))
share := &models.SaDocumentShare{
DocumentID: loaded.Document.ID, CreatedBy: userID, TokenHash: hex.EncodeToString(hash[:]),
ExpiresAt: s.now().UTC().Add(duration),
}
return share, token, s.database().Create(share).Error
}
func (s *Service) ListShares(userID uint, projectIdentity, documentIdentity string) ([]models.SaDocumentShare, error) {
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
if err != nil {
return nil, err
}
var shares []models.SaDocumentShare
err = s.database().Where("document_id = ?", loaded.Document.ID).Order("created_at desc").Find(&shares).Error
return shares, err
}
func (s *Service) RevokeShare(userID uint, projectIdentity, documentIdentity, shareIdentity string) error {
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
if err != nil {
return err
}
now := s.now().UTC()
result := s.database().Model(&models.SaDocumentShare{}).
Where("identity = ? AND document_id = ?", shareIdentity, loaded.Document.ID).
Update("revoked_at", &now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *Service) ResolveShare(token string) (*SharedDocument, error) {
hash := sha256.Sum256([]byte(strings.TrimSpace(token)))
var share models.SaDocumentShare
if err := s.database().Where(
"token_hash = ? AND revoked_at IS NULL AND expires_at > ?",
hex.EncodeToString(hash[:]), s.now().UTC(),
).First(&share).Error; err != nil {
return nil, ErrShareNotFound
}
var document models.SaDocumentTree
if err := s.database().Where("id = ?", share.DocumentID).First(&document).Error; err != nil {
return nil, ErrShareNotFound
}
return s.loadDocument(document)
}
func (s *Service) Export(document SharedDocument, format string) ([]byte, string, string, error) {
if document.Content == nil || document.Document.Extension != ".md" {
return nil, "", "", ErrUnsupportedExport
}
base := strings.TrimSuffix(document.Document.Name, filepath.Ext(document.Document.Name))
switch strings.ToLower(strings.TrimSpace(format)) {
case "md", "markdown":
return []byte(document.Content.Markdown), base + ".md", "text/markdown; charset=utf-8", nil
case "docx":
data, err := markdownDOCX(document.Content.Markdown)
return data, base + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", err
case "pdf":
data, err := markdownPDF(document.Content.Markdown)
return data, base + ".pdf", "application/pdf", err
default:
return nil, "", "", ErrUnsupportedExport
}
}
func (s *Service) loadDocument(document models.SaDocumentTree) (*SharedDocument, error) {
result := &SharedDocument{Document: document}
var content models.SaDocumentContent
if err := s.database().Where("document_id = ?", document.ID).First(&content).Error; err == nil {
result.Content = &content
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var blob models.SaDocumentBlob
if err := s.database().Where("document_id = ?", document.ID).First(&blob).Error; err == nil {
result.Blob = &blob
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return result, nil
}
func (s *Service) ownedProject(userID uint, identity string) (*models.SaProject, error) {
var project models.SaProject
err := s.database().Where("identity = ? AND owner_id = ?", strings.TrimSpace(identity), userID).First(&project).Error
return &project, err
}
func (s *Service) resolveParent(projectID uint, identity string) (*uint, *string, error) {
identity = strings.TrimSpace(identity)
if identity == "" {
return nil, nil, nil
}
var parent models.SaDocumentTree
if err := s.database().Where("identity = ? AND project_id = ? AND kind = ?", identity, projectID, models.DocumentKindFolder).First(&parent).Error; err != nil {
return nil, nil, ErrInvalidParent
}
return &parent.ID, &parent.Identity, nil
}
func (s *Service) availableName(projectID uint, parentID *uint, name string) (string, error) {
extension := filepath.Ext(name)
base := strings.TrimSuffix(name, extension)
for attempt := 0; attempt < 1000; attempt++ {
candidate := name
if attempt > 0 {
candidate = fmt.Sprintf("%s (%d)%s", base, attempt, extension)
}
if !s.nameExists(projectID, parentID, normalizeName(candidate), 0) {
return candidate, nil
}
}
return "", ErrNameConflict
}
func (s *Service) nameExists(projectID uint, parentID *uint, normalized string, exceptID uint) bool {
query := s.database().Model(&models.SaDocumentTree{}).Where("project_id = ? AND normalized_name = ?", projectID, normalized)
if parentID == nil {
query = query.Where("parent_id IS NULL")
} else {
query = query.Where("parent_id = ?", *parentID)
}
if exceptID > 0 {
query = query.Where("id <> ?", exceptID)
}
var count int64
return query.Count(&count).Error == nil && count > 0
}
func (s *Service) isDescendant(ancestorID, candidateID uint) (bool, error) {
current := candidateID
for current != 0 {
if current == ancestorID {
return true, nil
}
var document models.SaDocumentTree
if err := s.database().Unscoped().Select("parent_id").Where("id = ?", current).First(&document).Error; err != nil {
return false, err
}
if document.ParentID == nil {
return false, nil
}
current = *document.ParentID
}
return false, nil
}
func (s *Service) subtreeIDs(projectID, rootID uint) ([]uint, error) {
result := []uint{rootID}
frontier := []uint{rootID}
for len(frontier) > 0 {
var children []models.SaDocumentTree
if err := s.database().Select("id").Where("project_id = ? AND parent_id IN ?", projectID, frontier).Find(&children).Error; err != nil {
return nil, err
}
frontier = frontier[:0]
for _, child := range children {
result = append(result, child.ID)
frontier = append(frontier, child.ID)
}
}
return result, nil
}
func (s *Service) absolutePath(relative string) (string, error) {
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relative)))
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", ErrInvalidDocument
}
root, err := filepath.Abs(s.root)
if err != nil {
return "", err
}
absolute, err := filepath.Abs(filepath.Join(root, cleaned))
if err != nil {
return "", err
}
relativeToRoot, err := filepath.Rel(root, absolute)
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
return "", ErrInvalidDocument
}
return absolute, nil
}
func cleanDisplayName(value string) string {
return strings.TrimSpace(filepath.Base(strings.ReplaceAll(value, "\\", "/")))
}
func normalizeName(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return ' '
}
return unicode.ToLower(r)
}, strings.TrimSpace(value))
}
func identityValue(value *string) string {
if value == nil {
return ""
}
return *value
}
type limitedWriter struct {
writer io.Writer
remaining int
}
func (w *limitedWriter) Write(p []byte) (int, error) {
if w.remaining <= 0 {
return len(p), nil
}
size := len(p)
if size > w.remaining {
size = w.remaining
}
_, _ = w.writer.Write(p[:size])
w.remaining -= size
return len(p), nil
}
var markdownPrefix = regexp.MustCompile(`(?m)^#{1,6}\s+|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s?`)
func plainMarkdown(value string) string {
value = markdownPrefix.ReplaceAllString(value, "")
value = strings.NewReplacer("**", "", "__", "", "`", "", "\r", "").Replace(value)
return value
}
func markdownDOCX(markdown string) ([]byte, error) {
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
files := map[string]string{
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`,
"word/document.xml": docxDocumentXML(markdown),
}
for name, body := range files {
writer, err := archive.Create(name)
if err != nil {
return nil, err
}
if _, err := io.WriteString(writer, body); err != nil {
return nil, err
}
}
if err := archive.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func docxDocumentXML(markdown string) string {
var paragraphs strings.Builder
for _, line := range strings.Split(plainMarkdown(markdown), "\n") {
paragraphs.WriteString(`<w:p><w:r><w:t xml:space="preserve">`)
paragraphs.WriteString(html.EscapeString(line))
paragraphs.WriteString(`</w:t></w:r></w:p>`)
}
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
paragraphs.String() + `<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
}
func markdownPDF(markdown string) ([]byte, error) {
const (
pageWidth = 595.28
pageHeight = 841.89
marginX = 52.0
top = 56.0
bottom = 60.0
)
pdf := &gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: gopdf.Rect{W: pageWidth, H: pageHeight}})
if err := pdf.AddTTFFontData("NotoSansSC", documentPDFFont); err != nil {
return nil, fmt.Errorf("load document PDF font: %w", err)
}
pageNumber := 0
y := top
addPage := func() error {
if pageNumber > 0 {
if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil {
return err
}
}
pdf.AddPage()
pageNumber++
y = top
return nil
}
if err := addPage(); err != nil {
return nil, err
}
for _, rawLine := range strings.Split(strings.ReplaceAll(markdown, "\r", ""), "\n") {
line := plainMarkdown(rawLine)
fontSize := 11.0
lineHeight := 18.0
switch {
case strings.HasPrefix(rawLine, "# "):
fontSize, lineHeight = 22, 32
case strings.HasPrefix(rawLine, "## "):
fontSize, lineHeight = 18, 27
case strings.HasPrefix(rawLine, "### "):
fontSize, lineHeight = 15, 23
case strings.TrimSpace(rawLine) == "":
y += 9
continue
}
if err := pdf.SetFont("NotoSansSC", "", fontSize); err != nil {
return nil, err
}
lines, err := pdf.SplitTextWithWordWrap(line, pageWidth-2*marginX)
if err != nil {
return nil, err
}
if len(lines) == 0 {
lines = []string{""}
}
for _, wrapped := range lines {
if y+lineHeight > pageHeight-bottom {
if err := addPage(); err != nil {
return nil, err
}
}
pdf.SetX(marginX)
pdf.SetY(y)
if err := pdf.Cell(&gopdf.Rect{W: pageWidth - 2*marginX, H: lineHeight}, wrapped); err != nil {
return nil, err
}
y += lineHeight
}
y += 3
}
if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil {
return nil, err
}
var buffer bytes.Buffer
if err := pdf.Write(&buffer); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func writePDFFooter(pdf *gopdf.GoPdf, pageNumber int, pageWidth, pageHeight float64) error {
if err := pdf.SetFont("NotoSansSC", "", 9); err != nil {
return err
}
pdf.SetTextColor(120, 120, 120)
pdf.SetX(0)
pdf.SetY(pageHeight - 34)
err := pdf.CellWithOption(&gopdf.Rect{W: pageWidth, H: 14}, fmt.Sprintf("第 %d 页", pageNumber), gopdf.CellOption{Align: gopdf.Center})
pdf.SetTextColor(30, 30, 30)
return err
}