512 lines
14 KiB
Go
512 lines
14 KiB
Go
|
|
package usecase
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"crypto/sha256"
|
|||
|
|
"encoding/hex"
|
|||
|
|
"fmt"
|
|||
|
|
"math"
|
|||
|
|
"sort"
|
|||
|
|
"strings"
|
|||
|
|
"unicode"
|
|||
|
|
|
|||
|
|
"apps/backend/internal/module/growth/domain"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// --- Playbooks ---
|
|||
|
|
|
|||
|
|
func (s *Service) PublishPlaybook(ctx context.Context, ownerUID int64, kind, title, niche, body string, anonymous bool) (*domain.Playbook, error) {
|
|||
|
|
kind = strings.ToLower(strings.TrimSpace(kind))
|
|||
|
|
title = strings.TrimSpace(title)
|
|||
|
|
body = strings.TrimSpace(body)
|
|||
|
|
if title == "" || body == "" {
|
|||
|
|
return nil, fmt.Errorf("%w: title and body required", domain.ErrValidation)
|
|||
|
|
}
|
|||
|
|
switch kind {
|
|||
|
|
case domain.PlaybookBrief, domain.PlaybookPersona, domain.PlaybookPlay:
|
|||
|
|
default:
|
|||
|
|
return nil, fmt.Errorf("%w: kind must be brief|persona|play", domain.ErrValidation)
|
|||
|
|
}
|
|||
|
|
now := domain.NowNano()
|
|||
|
|
p := &domain.Playbook{
|
|||
|
|
ID: domain.NewID(), OwnerUID: ownerUID, Kind: kind, Title: title,
|
|||
|
|
Niche: strings.TrimSpace(niche), Body: body, Anonymous: anonymous,
|
|||
|
|
CreatedAt: now, UpdatedAt: now,
|
|||
|
|
}
|
|||
|
|
if err := s.Repo.SavePlaybook(ctx, p); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return p, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) ListPlaybooks(ctx context.Context, viewerUID int64, kind, niche string, mine bool, page, pageSize int) ([]*domain.Playbook, int64, error) {
|
|||
|
|
ownerOnly := int64(0)
|
|||
|
|
if mine {
|
|||
|
|
ownerOnly = viewerUID
|
|||
|
|
}
|
|||
|
|
return s.Repo.ListPlaybooks(ctx, kind, niche, ownerOnly, page, pageSize)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) GetPlaybook(ctx context.Context, id string) (*domain.Playbook, error) {
|
|||
|
|
return s.Repo.GetPlaybook(ctx, id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) ImportPlaybook(ctx context.Context, viewerUID int64, id string) (*domain.Playbook, error) {
|
|||
|
|
p, err := s.Repo.GetPlaybook(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
_ = s.Repo.IncPlaybookImport(ctx, id)
|
|||
|
|
// return a personal copy metadata (caller may store locally)
|
|||
|
|
now := domain.NowNano()
|
|||
|
|
copyPB := &domain.Playbook{
|
|||
|
|
ID: domain.NewID(), OwnerUID: viewerUID, Kind: p.Kind,
|
|||
|
|
Title: p.Title + "(引用)", Niche: p.Niche, Body: p.Body,
|
|||
|
|
Anonymous: false, CreatedAt: now, UpdatedAt: now,
|
|||
|
|
}
|
|||
|
|
if err := s.Repo.SavePlaybook(ctx, copyPB); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return copyPB, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) RemovePlaybook(ctx context.Context, ownerUID int64, id string) error {
|
|||
|
|
p, err := s.Repo.GetPlaybook(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if p.OwnerUID != ownerUID {
|
|||
|
|
return domain.ErrForbidden
|
|||
|
|
}
|
|||
|
|
return s.Repo.DeletePlaybook(ctx, id)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func PlaybookAuthorLabel(p *domain.Playbook, viewerUID int64) string {
|
|||
|
|
if p == nil {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
if p.OwnerUID == viewerUID {
|
|||
|
|
return "我"
|
|||
|
|
}
|
|||
|
|
if p.Anonymous {
|
|||
|
|
return "匿名島民"
|
|||
|
|
}
|
|||
|
|
return fmt.Sprintf("島民#%d", p.OwnerUID%10000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Free tools ---
|
|||
|
|
|
|||
|
|
func StyleQuiz(samples string) (tone, rhythm, hooks, avoid, summary string, tags []string) {
|
|||
|
|
text := strings.TrimSpace(samples)
|
|||
|
|
if text == "" {
|
|||
|
|
return "平實", "中等", "問題開頭", "避免空泛口號", "請貼上幾則你的貼文以分析風格。", []string{"待補充"}
|
|||
|
|
}
|
|||
|
|
runes := []rune(text)
|
|||
|
|
excl := strings.Count(text, "!") + strings.Count(text, "!")
|
|||
|
|
q := strings.Count(text, "?") + strings.Count(text, "?")
|
|||
|
|
avgLen := 0
|
|||
|
|
lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '。' })
|
|||
|
|
if len(lines) > 0 {
|
|||
|
|
total := 0
|
|||
|
|
for _, ln := range lines {
|
|||
|
|
total += len([]rune(ln))
|
|||
|
|
}
|
|||
|
|
avgLen = total / len(lines)
|
|||
|
|
}
|
|||
|
|
tone = "溫和敘事"
|
|||
|
|
if excl > 3 {
|
|||
|
|
tone = "熱情有力"
|
|||
|
|
} else if q > 3 {
|
|||
|
|
tone = "對話邀請"
|
|||
|
|
}
|
|||
|
|
rhythm = "中短句"
|
|||
|
|
if avgLen > 40 {
|
|||
|
|
rhythm = "長句鋪陳"
|
|||
|
|
} else if avgLen < 15 {
|
|||
|
|
rhythm = "短句節奏"
|
|||
|
|
}
|
|||
|
|
hooks = "經驗分享"
|
|||
|
|
if q > 0 {
|
|||
|
|
hooks = "提問開場"
|
|||
|
|
}
|
|||
|
|
avoid = "避免罐頭 AI 腔與過度標點"
|
|||
|
|
if excl > 5 {
|
|||
|
|
avoid = "注意驚嘆號密度,避免像廣告"
|
|||
|
|
}
|
|||
|
|
tags = []string{tone, rhythm, hooks}
|
|||
|
|
summary = fmt.Sprintf("從約 %d 字樣本看:語氣偏「%s」,節奏「%s」,常用「%s」。", len(runes), tone, rhythm, hooks)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func PainKeywords(productBrief, audience string) (keywords, pains, scan []string, summary string) {
|
|||
|
|
brief := strings.TrimSpace(productBrief)
|
|||
|
|
if brief == "" {
|
|||
|
|
return []string{"請描述產品"}, []string{"痛點待填"}, []string{"關鍵字"}, "請填產品簡述。"
|
|||
|
|
}
|
|||
|
|
// rule-based extraction: split CJK/words
|
|||
|
|
tokens := tokenizeTC(brief)
|
|||
|
|
for _, t := range tokens {
|
|||
|
|
if len([]rune(t)) < 2 {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
keywords = append(keywords, t)
|
|||
|
|
if len(keywords) >= 12 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
pains = []string{
|
|||
|
|
"不知道怎麼選",
|
|||
|
|
"用了沒感覺",
|
|||
|
|
"怕踩雷",
|
|||
|
|
"預算有限",
|
|||
|
|
}
|
|||
|
|
if audience != "" {
|
|||
|
|
pains = append([]string{audience + "常遇到的困擾"}, pains...)
|
|||
|
|
}
|
|||
|
|
scan = append([]string{}, keywords...)
|
|||
|
|
for _, p := range pains {
|
|||
|
|
scan = append(scan, p)
|
|||
|
|
}
|
|||
|
|
if len(scan) > 16 {
|
|||
|
|
scan = scan[:16]
|
|||
|
|
}
|
|||
|
|
summary = fmt.Sprintf("依產品描述抽出 %d 個掃描詞、%d 個痛點方向,可直接貼進海巡 brief。", len(keywords), len(pains))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func tokenizeTC(s string) []string {
|
|||
|
|
var cur strings.Builder
|
|||
|
|
var out []string
|
|||
|
|
flush := func() {
|
|||
|
|
t := strings.TrimSpace(cur.String())
|
|||
|
|
cur.Reset()
|
|||
|
|
if t != "" {
|
|||
|
|
out = append(out, t)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, r := range s {
|
|||
|
|
if unicode.Is(unicode.Han, r) {
|
|||
|
|
cur.WriteRune(r)
|
|||
|
|
if len([]rune(cur.String())) >= 4 {
|
|||
|
|
flush()
|
|||
|
|
}
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|||
|
|
cur.WriteRune(r)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
flush()
|
|||
|
|
}
|
|||
|
|
flush()
|
|||
|
|
// also bigrams from han
|
|||
|
|
han := make([]rune, 0)
|
|||
|
|
for _, r := range s {
|
|||
|
|
if unicode.Is(unicode.Han, r) {
|
|||
|
|
han = append(han, r)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for i := 0; i+1 < len(han); i++ {
|
|||
|
|
out = append(out, string(han[i:i+2]))
|
|||
|
|
}
|
|||
|
|
// dedupe preserve order
|
|||
|
|
seen := map[string]bool{}
|
|||
|
|
var uniq []string
|
|||
|
|
for _, t := range out {
|
|||
|
|
if seen[t] {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[t] = true
|
|||
|
|
uniq = append(uniq, t)
|
|||
|
|
}
|
|||
|
|
return uniq
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- UTM ---
|
|||
|
|
|
|||
|
|
func (s *Service) CreateUtmLink(ctx context.Context, ownerUID int64, dest, outcomeID, label string) (*domain.UtmLink, error) {
|
|||
|
|
dest = strings.TrimSpace(dest)
|
|||
|
|
if dest == "" || !strings.HasPrefix(dest, "http") {
|
|||
|
|
return nil, fmt.Errorf("%w: destination_url must be http(s)", domain.ErrValidation)
|
|||
|
|
}
|
|||
|
|
code := domain.NewID()[:10]
|
|||
|
|
u := &domain.UtmLink{
|
|||
|
|
ID: domain.NewID(), Code: code, OwnerUID: ownerUID,
|
|||
|
|
DestinationURL: dest, OutcomeID: strings.TrimSpace(outcomeID),
|
|||
|
|
Label: strings.TrimSpace(label), CreatedAt: domain.NowNano(),
|
|||
|
|
}
|
|||
|
|
if err := s.Repo.SaveUtmLink(ctx, u); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return u, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) ListUtmLinks(ctx context.Context, ownerUID int64) ([]*domain.UtmLink, error) {
|
|||
|
|
return s.Repo.ListUtmLinks(ctx, ownerUID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) TrackUtmClick(ctx context.Context, code string) (dest string, err error) {
|
|||
|
|
u, err := s.Repo.IncUtmClick(ctx, code)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
if u.OutcomeID != "" {
|
|||
|
|
if e, gerr := s.Repo.GetOutcome(ctx, u.OutcomeID); gerr == nil && e != nil {
|
|||
|
|
e.ClickCount++
|
|||
|
|
if e.Kind == domain.KindReach || e.Kind == "" {
|
|||
|
|
e.Kind = domain.KindClick
|
|||
|
|
e.Confidence = domain.ConfidenceConfirmed
|
|||
|
|
e.Status = domain.StatusConfirmed
|
|||
|
|
}
|
|||
|
|
e.UpdatedAt = domain.NowNano()
|
|||
|
|
_ = s.Repo.SaveOutcome(ctx, e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return u.DestinationURL, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Benchmark ---
|
|||
|
|
|
|||
|
|
func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
|
|||
|
|
niche = strings.TrimSpace(niche)
|
|||
|
|
if niche == "" {
|
|||
|
|
niche = "general"
|
|||
|
|
}
|
|||
|
|
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s", ownerUID, niche)))
|
|||
|
|
return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{
|
|||
|
|
Niche: niche, EngRate: engRate, AvgViews: avgViews,
|
|||
|
|
OwnerHash: hex.EncodeToString(h[:8]), UpdatedAt: domain.NowNano(),
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) GetBenchmark(ctx context.Context, niche string, yourEng, yourViews float64) (sampleSize int, medianEng, medianViews float64, available bool, hint string) {
|
|||
|
|
niche = strings.TrimSpace(niche)
|
|||
|
|
if niche == "" {
|
|||
|
|
niche = "general"
|
|||
|
|
}
|
|||
|
|
list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500)
|
|||
|
|
if err != nil || len(list) < 5 {
|
|||
|
|
return len(list), 0, 0, false, "樣本不足 5,暫不顯示中位數(多貢獻幾次成效後再看)"
|
|||
|
|
}
|
|||
|
|
engs := make([]float64, 0, len(list))
|
|||
|
|
views := make([]float64, 0, len(list))
|
|||
|
|
for _, s0 := range list {
|
|||
|
|
engs = append(engs, s0.EngRate)
|
|||
|
|
views = append(views, s0.AvgViews)
|
|||
|
|
}
|
|||
|
|
medianEng = medianFloat(engs)
|
|||
|
|
medianViews = medianFloat(views)
|
|||
|
|
hint = "約在中位附近"
|
|||
|
|
if yourEng > 0 {
|
|||
|
|
if yourEng >= medianEng*1.2 {
|
|||
|
|
hint = "你高於同利基中位數"
|
|||
|
|
} else if yourEng <= medianEng*0.8 {
|
|||
|
|
hint = "你低於同利基中位數,可調整題材/開頭"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return len(list), medianEng, medianViews, true, hint
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func medianFloat(xs []float64) float64 {
|
|||
|
|
if len(xs) == 0 {
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
sort.Float64s(xs)
|
|||
|
|
mid := len(xs) / 2
|
|||
|
|
if len(xs)%2 == 0 {
|
|||
|
|
return (xs[mid-1] + xs[mid]) / 2
|
|||
|
|
}
|
|||
|
|
return xs[mid]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Workspace members ---
|
|||
|
|
|
|||
|
|
func (s *Service) AddWorkspaceMember(ctx context.Context, actorUID, memberUID int64, workspaceID, role string) (*domain.WorkspaceMember, error) {
|
|||
|
|
w, err := s.Repo.GetWorkspace(ctx, workspaceID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if w.OwnerUID != actorUID {
|
|||
|
|
ok, r, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID)
|
|||
|
|
if !ok || r != domain.WSRoleOwner {
|
|||
|
|
return nil, domain.ErrForbidden
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if memberUID <= 0 {
|
|||
|
|
return nil, fmt.Errorf("%w: member_uid required", domain.ErrValidation)
|
|||
|
|
}
|
|||
|
|
role = strings.ToLower(strings.TrimSpace(role))
|
|||
|
|
if role == "" {
|
|||
|
|
role = domain.WSRoleReviewer
|
|||
|
|
}
|
|||
|
|
switch role {
|
|||
|
|
case domain.WSRoleEditor, domain.WSRoleReviewer, domain.WSRoleOwner:
|
|||
|
|
default:
|
|||
|
|
return nil, fmt.Errorf("%w: invalid role", domain.ErrValidation)
|
|||
|
|
}
|
|||
|
|
m := &domain.WorkspaceMember{
|
|||
|
|
ID: domain.NewID(), WorkspaceID: workspaceID, UID: memberUID, Role: role, JoinedAt: domain.NowNano(),
|
|||
|
|
}
|
|||
|
|
if err := s.Repo.SaveWorkspaceMember(ctx, m); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return m, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) ListWorkspaceMembers(ctx context.Context, actorUID int64, workspaceID string) ([]*domain.WorkspaceMember, error) {
|
|||
|
|
ok, _, err := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
w, werr := s.Repo.GetWorkspace(ctx, workspaceID)
|
|||
|
|
if werr != nil {
|
|||
|
|
return nil, werr
|
|||
|
|
}
|
|||
|
|
if !ok && w.OwnerUID != actorUID {
|
|||
|
|
return nil, domain.ErrForbidden
|
|||
|
|
}
|
|||
|
|
list, err := s.Repo.ListWorkspaceMembers(ctx, workspaceID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
// ensure owner present
|
|||
|
|
hasOwner := false
|
|||
|
|
for _, m := range list {
|
|||
|
|
if m.UID == w.OwnerUID {
|
|||
|
|
hasOwner = true
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if !hasOwner {
|
|||
|
|
list = append([]*domain.WorkspaceMember{{
|
|||
|
|
ID: "owner", WorkspaceID: workspaceID, UID: w.OwnerUID, Role: domain.WSRoleOwner, JoinedAt: w.CreatedAt,
|
|||
|
|
}}, list...)
|
|||
|
|
}
|
|||
|
|
return list, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) RemoveWorkspaceMember(ctx context.Context, actorUID, targetUID int64, workspaceID string) error {
|
|||
|
|
w, err := s.Repo.GetWorkspace(ctx, workspaceID)
|
|||
|
|
if err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
if w.OwnerUID != actorUID {
|
|||
|
|
return domain.ErrForbidden
|
|||
|
|
}
|
|||
|
|
if targetUID == w.OwnerUID {
|
|||
|
|
return fmt.Errorf("%w: cannot remove owner", domain.ErrValidation)
|
|||
|
|
}
|
|||
|
|
return s.Repo.RemoveWorkspaceMember(ctx, workspaceID, targetUID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *Service) UpdateWorkspaceBranding(ctx context.Context, ownerUID int64, id, footer, brandName string, hidePowered *bool) (*domain.Workspace, error) {
|
|||
|
|
w, err := s.Repo.GetWorkspace(ctx, id)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
if w.OwnerUID != ownerUID {
|
|||
|
|
return nil, domain.ErrForbidden
|
|||
|
|
}
|
|||
|
|
if footer != "" {
|
|||
|
|
w.FooterText = footer
|
|||
|
|
}
|
|||
|
|
if brandName != "" {
|
|||
|
|
w.BrandName = brandName
|
|||
|
|
}
|
|||
|
|
if hidePowered != nil {
|
|||
|
|
w.HidePoweredBy = *hidePowered
|
|||
|
|
}
|
|||
|
|
w.UpdatedAt = domain.NowNano()
|
|||
|
|
if err := s.Repo.SaveWorkspace(ctx, w); err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
return w, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// EnsureReviewAllowsAutoSendP2 — owner or any reviewer/editor/member may have approved
|
|||
|
|
func (s *Service) CanReviewInWorkspace(ctx context.Context, uid int64, workspaceID string) bool {
|
|||
|
|
w, err := s.Repo.GetWorkspace(ctx, workspaceID)
|
|||
|
|
if err != nil {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
if w.OwnerUID == uid {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
ok, role, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, uid)
|
|||
|
|
return ok && (role == domain.WSRoleOwner || role == domain.WSRoleReviewer || role == domain.WSRoleEditor)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ExportReportPDF returns minimal valid PDF bytes
|
|||
|
|
func ExportReportPDF(title, body string) []byte {
|
|||
|
|
// Escape PDF strings
|
|||
|
|
esc := func(s string) string {
|
|||
|
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
|||
|
|
s = strings.ReplaceAll(s, "(", "\\(")
|
|||
|
|
s = strings.ReplaceAll(s, ")", "\\)")
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
// simple multi-line text
|
|||
|
|
lines := strings.Split(body, "\n")
|
|||
|
|
if len(lines) > 40 {
|
|||
|
|
lines = lines[:40]
|
|||
|
|
}
|
|||
|
|
var content strings.Builder
|
|||
|
|
content.WriteString("BT /F1 14 Tf 50 780 Td (" + esc(title) + ") Tj\n")
|
|||
|
|
content.WriteString("/F1 10 Tf 0 -24 Td\n")
|
|||
|
|
for i, ln := range lines {
|
|||
|
|
if i > 0 {
|
|||
|
|
content.WriteString("0 -14 Td\n")
|
|||
|
|
}
|
|||
|
|
// PDF standard fonts poor for CJK — keep ASCII fallback note
|
|||
|
|
safe := toPDFSafe(ln)
|
|||
|
|
content.WriteString("(" + esc(safe) + ") Tj\n")
|
|||
|
|
}
|
|||
|
|
content.WriteString("ET")
|
|||
|
|
stream := content.String()
|
|||
|
|
objs := []string{
|
|||
|
|
"1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n",
|
|||
|
|
"2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n",
|
|||
|
|
"3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources<< /Font<< /F1 5 0 R >> >> >>endobj\n",
|
|||
|
|
fmt.Sprintf("4 0 obj<< /Length %d >>stream\n%s\nendstream endobj\n", len(stream), stream),
|
|||
|
|
"5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj\n",
|
|||
|
|
}
|
|||
|
|
var pdf strings.Builder
|
|||
|
|
pdf.WriteString("%PDF-1.4\n")
|
|||
|
|
offsets := []int{0}
|
|||
|
|
for _, o := range objs {
|
|||
|
|
offsets = append(offsets, pdf.Len())
|
|||
|
|
pdf.WriteString(o)
|
|||
|
|
}
|
|||
|
|
xref := pdf.Len()
|
|||
|
|
pdf.WriteString(fmt.Sprintf("xref\n0 %d\n", len(offsets)))
|
|||
|
|
pdf.WriteString("0000000000 65535 f \n")
|
|||
|
|
for i := 1; i < len(offsets); i++ {
|
|||
|
|
pdf.WriteString(fmt.Sprintf("%010d 00000 n \n", offsets[i]))
|
|||
|
|
}
|
|||
|
|
pdf.WriteString(fmt.Sprintf("trailer<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref))
|
|||
|
|
return []byte(pdf.String())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toPDFSafe(s string) string {
|
|||
|
|
var b strings.Builder
|
|||
|
|
for _, r := range s {
|
|||
|
|
if r < 128 && r >= 32 {
|
|||
|
|
b.WriteRune(r)
|
|||
|
|
} else if r == '\t' {
|
|||
|
|
b.WriteByte(' ')
|
|||
|
|
} else {
|
|||
|
|
// replace non-latin with ?
|
|||
|
|
b.WriteByte('?')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out := b.String()
|
|||
|
|
if strings.Trim(out, "? ") == "" && s != "" {
|
|||
|
|
return "[Chinese content — open markdown export for full text]"
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func Round2(v float64) float64 {
|
|||
|
|
return math.Round(v*100) / 100
|
|||
|
|
}
|