Compare commits

...

3 Commits

Author SHA1 Message Date
haopengzhan 8b9cd80d41 feat(football): implement World Cup 2026 simulator and Empirical-Poisson Mixture Model
Build and Push Docker Image / build (push) Successful in 3m38s
2026-06-19 16:15:13 -07:00
haopengzhan c21f572e1f test: update E2E test to use build directory and fix linter issues 2026-03-01 23:50:20 -08:00
haopengzhan 5c185da0a9 chore: optimize SEO and fix golangci-lint issues
- Implement dynamic SEO meta tags and sitemap.xml for toolbox.pengzhan.dev
- Fix SPA title/meta refresh issue in layout.html
- Address golangci-lint findings:
  - Refactor switch-case for tool path and SVG token parsing
  - Replace math.Pow with direct multiplication for performance
  - Fix unhandled error returns and unused imports
  - Omit redundant type declarations
2026-03-01 09:54:04 -08:00
19 changed files with 1702 additions and 229 deletions
+1
View File
@@ -1,4 +1,5 @@
# Binaries
build/
toolbox
*.exe
*.exe~
+49 -4
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"embed"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
@@ -12,6 +13,7 @@ import (
"sort"
"strconv"
"toolbox/pkg/base"
_ "toolbox/pkg/football"
_ "toolbox/pkg/learnnumber"
_ "toolbox/pkg/zitie" // 匿名导入以触发 init()
@@ -56,6 +58,23 @@ func main() {
subFS, _ := fs.Sub(webFS, "web")
r.StaticFS("/static", http.FS(subFS))
// 2.5 SEO 静态文件映射
r.GET("/robots.txt", func(c *gin.Context) {
c.String(http.StatusOK, "User-agent: *\nAllow: /\nSitemap: https://toolbox.pengzhan.dev/sitemap.xml\n")
})
r.GET("/sitemap.xml", func(c *gin.Context) {
var buf bytes.Buffer
buf.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
buf.WriteString("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n")
buf.WriteString(" <url><loc>https://toolbox.pengzhan.dev/</loc><priority>1.0</priority></url>\n")
// 动态根据注册的工具生成路由
for id := range base.Registry {
fmt.Fprintf(&buf, " <url><loc>https://toolbox.pengzhan.dev/%s</loc><priority>0.8</priority></url>\n", id)
}
buf.WriteString("</urlset>")
c.Data(http.StatusOK, "application/xml; charset=utf-8", buf.Bytes())
})
// 3. 模板引擎初始化
// 递归加载 web 目录下所有的 .html 文件
tmpl, err := template.ParseFS(subFS, "layout.html", "index.html", "tools/*.html")
@@ -65,9 +84,34 @@ func main() {
// 通用页面渲染函数
serveIndex := func(c *gin.Context) {
path := c.Request.URL.Path
title := "探索工具箱"
desc := "基于 Go 语言构建的模块化个人工具箱,提供汉字字帖生成、数字学习等多种实用生产力工具。"
keywords := "个人工具箱, 汉字字帖生成, 书法练习, 数字学习, Own-Tools"
baseURL := "https://toolbox.pengzhan.dev"
canonical := baseURL + path
// 针对不同路径设置 SEO 标签
switch path {
case "/zitie":
title = "汉字字帖生成器 - 教学方格与步进式分解"
desc = "在线生成 2x3 教学方格字帖和 9 列步进式笔顺分解字帖,支持多种书法字体和古风排版。"
keywords = "字帖生成, 笔顺分解, 汉字教学, 书法字帖, 练字"
case "/learn-number":
title = "趣味数字学习工具"
desc = "为儿童设计的数字学习与计数练习工具,生动活泼,寓教于乐。"
keywords = "数字学习, 儿童计数, 幼小衔接"
case "/football":
title = "2026世界杯小组出线模拟器 - 蒙特卡洛预测"
desc = "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。"
keywords = "2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测"
}
data := gin.H{
"Title": "Own-Tools",
"GA_ID": gaID,
"Title": title,
"Description": desc,
"Keywords": keywords,
"CanonicalURL": canonical,
"GA_ID": gaID,
}
var buf bytes.Buffer
@@ -78,12 +122,13 @@ func main() {
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
r.GET("/", serveIndex)
r.GET("/api/tools", func(c *gin.Context) {
var list []map[string]string
ids := make([]string, 0, len(base.Registry))
for id := range base.Registry { ids = append(ids, id) }
for id := range base.Registry {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"testing"
"time"
)
const (
testPort = "9999"
baseURL = "http://localhost:" + testPort
)
func TestToolboxE2E(t *testing.T) {
// 1. Build the binary
fmt.Println("--- Step 1: Building binary... ---")
_ = os.MkdirAll("build", 0755)
buildPath := "build/toolbox_test_bin"
buildCmd := exec.Command("go", "build", "-o", buildPath, "main.go")
if err := buildCmd.Run(); err != nil {
t.Fatalf("Failed to build binary: %v", err)
}
defer func() { _ = os.Remove(buildPath) }()
// 2. Start the server
fmt.Println("--- Step 2: Starting server... ---")
serverCmd := exec.Command("./"+buildPath, "-port", testPort)
if err := serverCmd.Start(); err != nil {
t.Fatalf("Failed to start server: %v", err)
}
defer func() {
if serverCmd.Process != nil {
_ = serverCmd.Process.Kill()
}
}()
// 3. Wait for server to be ready
fmt.Println("--- Step 3: Waiting for server readiness... ---")
ready := false
for i := 0; i < 10; i++ {
resp, err := http.Get(baseURL + "/api/tools")
if err == nil && resp.StatusCode == http.StatusOK {
ready = true
_ = resp.Body.Close()
break
}
time.Sleep(500 * time.Millisecond)
}
if !ready {
t.Fatal("Server failed to become ready in time")
}
// 4. Test endpoints
fmt.Println("--- Step 4: Testing tool endpoints... ---")
testOutputDir, err := os.MkdirTemp("", "toolbox_test_output_*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
fmt.Printf("--- Test Artifacts will be saved to: %s ---\n", testOutputDir)
tests := []struct {
name string
path string
method string
fileName string
body interface{}
}{
{
name: "Zitie Teaching",
path: "/api/zitie/teaching",
method: "POST",
fileName: "zitie_teaching.pdf",
body: map[string]string{"chars": "永和九年", "paper_size": "A4"},
},
{
name: "Zitie Step",
path: "/api/zitie/step",
method: "POST",
fileName: "zitie_step.pdf",
body: map[string]string{"chars": "永", "paper_size": "A4"},
},
{
name: "Zitie Manuscript",
path: "/api/zitie/manuscript",
method: "POST",
fileName: "zitie_manuscript.pdf",
body: map[string]string{"chars": "天道酬勤", "paper_size": "A4", "font_type": "kaiti"},
},
{
name: "Learn Number Counting",
path: "/api/learn-number/counting",
method: "POST",
fileName: "counting.pdf",
body: map[string]interface{}{"page_count": 1, "paper_size": "A4"},
},
{
name: "Learn Number Writing",
path: "/api/learn-number/writing",
method: "POST",
fileName: "writing.pdf",
body: map[string]interface{}{"start_num": 1, "end_num": 5, "paper_size": "A4"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var resp *http.Response
var err error
if tt.method == "POST" {
b, _ := json.Marshal(tt.body)
resp, err = http.Post(baseURL+tt.path, "application/json", bytes.NewBuffer(b))
} else {
resp, err = http.Get(baseURL + tt.path)
}
if err != nil {
t.Errorf("%s failed: %v", tt.name, err)
return
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Errorf("%s returned status %d", tt.name, resp.StatusCode)
return
}
contentType := resp.Header.Get("Content-Type")
if contentType != "application/pdf" {
t.Errorf("%s expected application/pdf, got %s", tt.name, contentType)
} else {
// Save PDF to temp directory for human review
outPath := testOutputDir + "/" + tt.fileName
f, err := os.Create(outPath)
if err != nil {
t.Errorf("Failed to create output file: %v", err)
return
}
defer func() { _ = f.Close() }()
_, err = f.ReadFrom(resp.Body)
if err != nil {
t.Errorf("Failed to save PDF: %v", err)
return
}
fmt.Printf("[PASS] %s: Saved to %s\n", tt.name, outPath)
}
})
}
fmt.Printf("\n--- ALL TESTS DONE. PLEASE REVIEW PDFS IN: %s ---\n", testOutputDir)
}
+1
View File
@@ -14,6 +14,7 @@
<!-- Tools Panels Containers -->
{{template "zitie" .}}
{{template "learn_number" .}}
{{template "football" .}}
<script>
// 首页 Tile 渲染
+55 -1
View File
@@ -4,6 +4,24 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} | Own-Tools</title>
<meta name="description" content="{{.Description}}">
<meta name="keywords" content="{{.Keywords}}">
<link rel="canonical" href="{{.CanonicalURL}}">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="{{.CanonicalURL}}">
<meta property="og:title" content="{{.Title}} | Own-Tools">
<meta property="og:description" content="{{.Description}}">
<meta property="og:image" content="/static/og-image.png">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="{{.CanonicalURL}}">
<meta property="twitter:title" content="{{.Title}} | Own-Tools">
<meta property="twitter:description" content="{{.Description}}">
<meta property="twitter:image" content="/static/og-image.png">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🖋️</text></svg>">
{{if .GA_ID}}
@@ -126,10 +144,46 @@
renderCurrentPath();
}
const metaConfig = {
'welcome': {
title: '探索工具箱',
desc: '基于 Go 语言构建的模块化个人工具箱,提供汉字字帖生成、数字学习等多种实用生产力工具。',
keywords: '个人工具箱, 汉字字帖生成, 书法练习, 数字学习, Own-Tools'
},
'zitie': {
title: '汉字字帖生成器 - 教学方格与步进式分解',
desc: '在线生成 2x3 教学方格字帖和 9 列步进式笔顺分解字帖,支持多种书法字体和古风排版。',
keywords: '字帖生成, 笔顺分解, 汉字教学, 书法字帖, 练字'
},
'learn-number': {
title: '趣味数字学习工具',
desc: '为儿童设计的数字学习与计数练习工具,生动活泼,寓教力乐。',
keywords: '数字学习, 儿童计数, 幼小衔接'
},
'football': {
title: '2026世界杯小组出线模拟器',
desc: '使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛出线概率,支持自定义比赛概率、小组球队及已有赛果。',
keywords: '2026世界杯, 小组出线概率, 蒙特卡洛模拟, 足球预测'
}
};
function renderCurrentPath() {
const path = window.location.pathname.replace('/', '') || 'welcome';
// 隐藏所有面板
// 1. 更新标题与元数据
const meta = metaConfig[path] || metaConfig['welcome'];
document.title = `${meta.title} | Own-Tools`;
document.querySelector('meta[name="description"]').setAttribute('content', meta.desc);
document.querySelector('meta[name="keywords"]').setAttribute('content', meta.keywords);
// 更新 Open Graph 标签 (可选,社交分享主要看服务端渲染)
const canonical = `https://toolbox.pengzhan.dev/${path === 'welcome' ? '' : path}`;
document.querySelector('link[rel="canonical"]').setAttribute('href', canonical);
document.querySelector('meta[property="og:title"]').setAttribute('content', `${meta.title} | Own-Tools`);
document.querySelector('meta[property="og:description"]').setAttribute('content', meta.desc);
document.querySelector('meta[property="og:url"]').setAttribute('content', canonical);
// 2. 隐藏所有面板
document.querySelectorAll('.tool-panel').forEach(p => p.classList.remove('active'));
// 显示目标面板
+44
View File
@@ -0,0 +1,44 @@
package logic
// Team represents a football team.
type Team struct {
Name string `json:"name"`
Group string `json:"group"` // e.g., "A", "B", ..., "L"
}
// Match represents a group stage match between two teams.
type Match struct {
TeamA string `json:"team_a"`
TeamB string `json:"team_b"`
IsCompleted bool `json:"is_completed"`
ScoreA int `json:"score_a"`
ScoreB int `json:"score_b"`
}
// ScoreOutcome represents a possible match score and its probability.
type ScoreOutcome struct {
ScoreA int `json:"score_a"`
ScoreB int `json:"score_b"`
Probability float64 `json:"probability"`
}
// TeamStats holds the metrics used to rank teams within a group or across third-place teams.
type TeamStats struct {
TeamName string
Points int
GoalDifference int
GoalsScored int
// Fields used for tie-breaking comparison within the group
H2HPoints int
H2HGD int
H2HGS int
}
// TeamProbability represents the calculated qualification probabilities for a single team.
type TeamProbability struct {
TeamName string `json:"team_name"`
Group string `json:"group"`
DirectQualProb float64 `json:"direct_qual_prob"`
ThirdQualProb float64 `json:"third_qual_prob"`
TotalQualProb float64 `json:"total_qual_prob"`
}
+191
View File
@@ -0,0 +1,191 @@
package logic
import (
"math"
"math/rand"
"sync"
)
// RawScoreRecord represents the raw statistics for a scoreline in football history.
type RawScoreRecord struct {
GoalsA int `json:"goals_a"`
GoalsB int `json:"goals_b"`
Count int `json:"count"`
}
// WorldCupRawScores represents the actual historical frequencies of scorelines
// in the FIFA World Cup history (964 matches from 1930 to 2022).
var WorldCupRawScores = []RawScoreRecord{
{GoalsA: 1, GoalsB: 0, Count: 182},
{GoalsA: 2, GoalsB: 1, Count: 152},
{GoalsA: 2, GoalsB: 0, Count: 111},
{GoalsA: 1, GoalsB: 1, Count: 92},
{GoalsA: 0, GoalsB: 0, Count: 78},
{GoalsA: 3, GoalsB: 1, Count: 68},
{GoalsA: 3, GoalsB: 0, Count: 57},
{GoalsA: 3, GoalsB: 2, Count: 43},
{GoalsA: 2, GoalsB: 2, Count: 35},
{GoalsA: 4, GoalsB: 1, Count: 31},
{GoalsA: 4, GoalsB: 0, Count: 24},
{GoalsA: 4, GoalsB: 2, Count: 17},
{GoalsA: 6, GoalsB: 1, Count: 11},
{GoalsA: 5, GoalsB: 2, Count: 9},
{GoalsA: 5, GoalsB: 0, Count: 7},
{GoalsA: 3, GoalsB: 3, Count: 7},
{GoalsA: 5, GoalsB: 1, Count: 7},
{GoalsA: 6, GoalsB: 0, Count: 5},
{GoalsA: 7, GoalsB: 0, Count: 5},
{GoalsA: 4, GoalsB: 3, Count: 3},
{GoalsA: 7, GoalsB: 1, Count: 3},
{GoalsA: 8, GoalsB: 1, Count: 3},
{GoalsA: 4, GoalsB: 4, Count: 2},
{GoalsA: 6, GoalsB: 3, Count: 2},
{GoalsA: 9, GoalsB: 0, Count: 2},
{GoalsA: 5, GoalsB: 3, Count: 1},
{GoalsA: 6, GoalsB: 2, Count: 1},
{GoalsA: 7, GoalsB: 2, Count: 1},
{GoalsA: 7, GoalsB: 3, Count: 1},
{GoalsA: 6, GoalsB: 5, Count: 1},
{GoalsA: 8, GoalsB: 3, Count: 1},
{GoalsA: 10, GoalsB: 1, Count: 1},
{GoalsA: 7, GoalsB: 5, Count: 1},
}
// poissonProbability computes the Poisson probability P(k; lambda).
func poissonProbability(lambda float64, k int) float64 {
factorial := 1.0
for i := 1; i <= k; i++ {
factorial *= float64(i)
}
return (math.Pow(lambda, float64(k)) * math.Exp(-lambda)) / factorial
}
// DefaultScoreOutcomes returns the symmetric default probability distribution based on WorldCupRawScores.
func DefaultScoreOutcomes() []ScoreOutcome {
var totalGoals int
var totalMatches int
for _, r := range WorldCupRawScores {
totalGoals += (r.GoalsA + r.GoalsB) * r.Count
totalMatches += r.Count
}
if totalMatches == 0 {
return []ScoreOutcome{{ScoreA: 1, ScoreB: 1, Probability: 1.0}}
}
// 1. Calculate expected goals per team (lambda)
lambda := float64(totalGoals) / (2.0 * float64(totalMatches))
// 2. Compute Poisson 1D and 2D probabilities (up to MaxGoals = 10)
maxGoals := 10
poisson1D := make([]float64, maxGoals+1)
var sumP float64
for g := 0; g <= maxGoals; g++ {
p := poissonProbability(lambda, g)
poisson1D[g] = p
sumP += p
}
for g := 0; g <= maxGoals; g++ {
poisson1D[g] /= sumP
}
// 3. Build Empirical 2D map
type scoreKey struct {
goalsA int
goalsB int
}
empirical := make(map[scoreKey]float64)
for _, r := range WorldCupRawScores {
if r.GoalsA <= maxGoals && r.GoalsB <= maxGoals {
prob := float64(r.Count) / float64(totalMatches)
if r.GoalsA == r.GoalsB {
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob
} else {
empirical[scoreKey{r.GoalsA, r.GoalsB}] = prob / 2.0
empirical[scoreKey{r.GoalsB, r.GoalsA}] = prob / 2.0
}
}
}
// 4. Combine into Mixture Model (alpha = 0.99)
alpha := 0.99
var outcomes []ScoreOutcome
for ga := 0; ga <= maxGoals; ga++ {
for gb := 0; gb <= maxGoals; gb++ {
pPoisson := poisson1D[ga] * poisson1D[gb]
pEmpirical := empirical[scoreKey{ga, gb}]
pMix := alpha*pEmpirical + (1.0-alpha)*pPoisson
outcomes = append(outcomes, ScoreOutcome{
ScoreA: ga,
ScoreB: gb,
Probability: pMix,
})
}
}
// Normalize just in case of float64 precision drift
var sumMix float64
for _, o := range outcomes {
sumMix += o.Probability
}
for i := range outcomes {
outcomes[i].Probability /= sumMix
}
return outcomes
}
// ScoreModel holds the probability distribution used to sample random scores.
type ScoreModel struct {
Outcomes []ScoreOutcome
mu sync.RWMutex
}
// NewScoreModel creates a ScoreModel from custom score outcomes.
// If customOutcomes is empty, it uses DefaultScoreOutcomes().
func NewScoreModel(customOutcomes []ScoreOutcome) *ScoreModel {
sm := &ScoreModel{}
if len(customOutcomes) > 0 {
// Normalize custom outcomes to ensure they sum to 1.0
var sum float64
for _, o := range customOutcomes {
sum += o.Probability
}
if sum > 0 {
normalized := make([]ScoreOutcome, len(customOutcomes))
for i, o := range customOutcomes {
normalized[i] = ScoreOutcome{
ScoreA: o.ScoreA,
ScoreB: o.ScoreB,
Probability: o.Probability / sum,
}
}
sm.Outcomes = normalized
return sm
}
}
sm.Outcomes = DefaultScoreOutcomes()
return sm
}
// RandomScore samples a random score outcome based on the probability distribution.
func (sm *ScoreModel) RandomScore(r *rand.Rand) ScoreOutcome {
sm.mu.RLock()
outcomes := sm.Outcomes
sm.mu.RUnlock()
if len(outcomes) == 0 {
return ScoreOutcome{ScoreA: 0, ScoreB: 0, Probability: 1.0}
}
p := r.Float64()
var cumulative float64
for _, outcome := range outcomes {
cumulative += outcome.Probability
if p <= cumulative {
return outcome
}
}
return outcomes[len(outcomes)-1]
}
+125
View File
@@ -0,0 +1,125 @@
package logic
import (
"math/rand"
"sort"
"time"
)
// RunMonteCarlo runs a Monte Carlo simulation of the remaining matches.
// Returns a slice of TeamProbability sorted by Group and then by TotalQualProb descending.
func RunMonteCarlo(teams []Team, matches []Match, scoreOutcomes []ScoreOutcome, numSimulations int) []TeamProbability {
if numSimulations <= 0 {
numSimulations = 50000
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
sm := NewScoreModel(scoreOutcomes)
// Map teams to groups
teamGroups := make(map[string]string)
groupsTeams := make(map[string][]string)
for _, t := range teams {
teamGroups[t.Name] = t.Group
groupsTeams[t.Group] = append(groupsTeams[t.Group], t.Name)
}
directCounts := make(map[string]int)
thirdCounts := make(map[string]int)
// Separate completed and uncompleted matches
var completed []Match
var uncompleted []Match
for _, m := range matches {
if m.IsCompleted {
completed = append(completed, m)
} else {
uncompleted = append(uncompleted, m)
}
}
for sim := 0; sim < numSimulations; sim++ {
// Clone completed matches and simulate uncompleted ones
simMatches := make([]Match, len(completed)+len(uncompleted))
copy(simMatches, completed)
for i, m := range uncompleted {
outcome := sm.RandomScore(r)
simMatches[len(completed)+i] = Match{
TeamA: m.TeamA,
TeamB: m.TeamB,
IsCompleted: true,
ScoreA: outcome.ScoreA,
ScoreB: outcome.ScoreB,
}
}
// Calculate standings for each group
thirdPlaced := make([]*TeamStats, 0, 12)
for grp, grpTeams := range groupsTeams {
var grpMatches []Match
for _, m := range simMatches {
if teamGroups[m.TeamA] == grp && teamGroups[m.TeamB] == grp {
grpMatches = append(grpMatches, m)
}
}
// Shuffle group teams to randomize tie-breaks in SortGroupStandings
shuffledGrpTeams := make([]string, len(grpTeams))
copy(shuffledGrpTeams, grpTeams)
r.Shuffle(len(shuffledGrpTeams), func(i, j int) {
shuffledGrpTeams[i], shuffledGrpTeams[j] = shuffledGrpTeams[j], shuffledGrpTeams[i]
})
standings := CalculateStandings(shuffledGrpTeams, grpMatches)
// Top 2 qualify directly
if len(standings) > 0 {
directCounts[standings[0].TeamName]++
}
if len(standings) > 1 {
directCounts[standings[1].TeamName]++
}
// 3rd placed team goes to the pool
if len(standings) > 2 {
thirdPlaced = append(thirdPlaced, standings[2])
}
}
// Shuffle third-placed list to randomize cross-group tie-breaks in CompareThirdPlaced
r.Shuffle(len(thirdPlaced), func(i, j int) {
thirdPlaced[i], thirdPlaced[j] = thirdPlaced[j], thirdPlaced[i]
})
CompareThirdPlaced(thirdPlaced)
limit := 8
if len(thirdPlaced) < limit {
limit = len(thirdPlaced)
}
for i := 0; i < limit; i++ {
thirdCounts[thirdPlaced[i].TeamName]++
}
}
// Build result slice
res := make([]TeamProbability, 0, len(teams))
for _, t := range teams {
dQual := float64(directCounts[t.Name]) / float64(numSimulations)
tQual := float64(thirdCounts[t.Name]) / float64(numSimulations)
res = append(res, TeamProbability{
TeamName: t.Name,
Group: t.Group,
DirectQualProb: dQual,
ThirdQualProb: tQual,
TotalQualProb: dQual + tQual,
})
}
// Sort results by Group name first, then by TotalQualProb descending
sort.Slice(res, func(i, j int) bool {
if res[i].Group != res[j].Group {
return res[i].Group < res[j].Group
}
return res[i].TotalQualProb > res[j].TotalQualProb
})
return res
}
+54
View File
@@ -0,0 +1,54 @@
package logic
import (
"testing"
)
func TestCalculateStandings(t *testing.T) {
teams := []string{"MEX", "RSA", "KOR", "CZE"}
matches := []Match{
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // MEX: 3 pts, RSA: 0 pts
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // KOR: 3 pts, CZE: 0 pts
}
standings := CalculateStandings(teams, matches)
if len(standings) != 4 {
t.Fatalf("expected 4 teams, got %d", len(standings))
}
// MEX and KOR should be top 2
if standings[0].TeamName != "MEX" && standings[0].TeamName != "KOR" {
t.Errorf("expected MEX or KOR as first, got %s", standings[0].TeamName)
}
}
func TestRunMonteCarlo(t *testing.T) {
teams := []Team{
{Name: "MEX", Group: "A"},
{Name: "RSA", Group: "A"},
{Name: "KOR", Group: "A"},
{Name: "CZE", Group: "A"},
}
matches := []Match{
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0},
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1},
{TeamA: "CZE", TeamB: "RSA", IsCompleted: false},
{TeamA: "MEX", TeamB: "KOR", IsCompleted: false},
{TeamA: "MEX", TeamB: "CZE", IsCompleted: false},
{TeamA: "KOR", TeamB: "RSA", IsCompleted: false},
}
// We only simulate Group A here (4 teams, 6 matches total).
// Note: 3rd place comparison won't succeed in putting any team to top 8 of third placed
// unless there are other groups, but it shouldn't crash.
probs := RunMonteCarlo(teams, matches, nil, 100)
if len(probs) != 4 {
t.Fatalf("expected probabilities for 4 teams, got %d", len(probs))
}
for _, p := range probs {
if p.TotalQualProb < 0 || p.TotalQualProb > 1.0 {
t.Errorf("invalid total qual probability for %s: %f", p.TeamName, p.TotalQualProb)
}
}
}
+161
View File
@@ -0,0 +1,161 @@
package logic
import (
"sort"
)
// CalculateStandings computes the stats for each team in a group given the match results.
func CalculateStandings(teams []string, matches []Match) []*TeamStats {
statsMap := make(map[string]*TeamStats)
for _, t := range teams {
statsMap[t] = &TeamStats{TeamName: t}
}
for _, m := range matches {
if !m.IsCompleted {
continue
}
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
if sa == nil || sb == nil {
continue
}
sa.GoalsScored += m.ScoreA
sa.GoalDifference += m.ScoreA - m.ScoreB
sb.GoalsScored += m.ScoreB
sb.GoalDifference += m.ScoreB - m.ScoreA
if m.ScoreA > m.ScoreB {
sa.Points += 3
} else if m.ScoreA < m.ScoreB {
sb.Points += 3
} else {
sa.Points += 1
sb.Points += 1
}
}
res := make([]*TeamStats, 0, len(teams))
for _, s := range statsMap {
res = append(res, s)
}
// Sort the standings
SortGroupStandings(res, matches)
return res
}
// SortGroupStandings sorts the teams in a group based on the tie-breaking rules.
func SortGroupStandings(teams []*TeamStats, matches []Match) {
sort.Slice(teams, func(i, j int) bool {
ti, tj := teams[i], teams[j]
// 1. Points
if ti.Points != tj.Points {
return ti.Points > tj.Points
}
// 2. Goal Difference
if ti.GoalDifference != tj.GoalDifference {
return ti.GoalDifference > tj.GoalDifference
}
// 3. Goals Scored
if ti.GoalsScored != tj.GoalsScored {
return ti.GoalsScored > tj.GoalsScored
}
// 4-6. Head-to-Head (H2H)
// We dynamically compute H2H stats for all teams that are tied on Points, GD, and GS.
// Since we are sorting, we can check the relationship between ti and tj.
// First find all teams tied with ti and tj.
tiedTeams := []string{}
for _, t := range teams {
if t.Points == ti.Points && t.GoalDifference == ti.GoalDifference && t.GoalsScored == ti.GoalsScored {
tiedTeams = append(tiedTeams, t.TeamName)
}
}
if len(tiedTeams) > 1 {
h2hStats := computeH2H(tiedTeams, matches)
h2hi := h2hStats[ti.TeamName]
h2hj := h2hStats[tj.TeamName]
if h2hi != nil && h2hj != nil {
// H2H Points
if h2hi.Points != h2hj.Points {
return h2hi.Points > h2hj.Points
}
// H2H GD
if h2hi.GoalDifference != h2hj.GoalDifference {
return h2hi.GoalDifference > h2hj.GoalDifference
}
// H2H GS
if h2hi.GoalsScored != h2hj.GoalsScored {
return h2hi.GoalsScored > h2hj.GoalsScored
}
}
}
// 7. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
return false
})
}
// computeH2H calculates Points, GD, and GS only considering matches between the specified tied teams.
func computeH2H(tiedTeams []string, matches []Match) map[string]*TeamStats {
statsMap := make(map[string]*TeamStats)
isTied := make(map[string]bool)
for _, t := range tiedTeams {
statsMap[t] = &TeamStats{TeamName: t}
isTied[t] = true
}
for _, m := range matches {
if !m.IsCompleted {
continue
}
if isTied[m.TeamA] && isTied[m.TeamB] {
sa, sb := statsMap[m.TeamA], statsMap[m.TeamB]
sa.GoalsScored += m.ScoreA
sa.GoalDifference += m.ScoreA - m.ScoreB
sb.GoalsScored += m.ScoreB
sb.GoalDifference += m.ScoreB - m.ScoreA
if m.ScoreA > m.ScoreB {
sa.Points += 3
} else if m.ScoreA < m.ScoreB {
sb.Points += 3
} else {
sa.Points += 1
sb.Points += 1
}
}
}
return statsMap
}
// CompareThirdPlaced ranks the third-placed teams across groups.
func CompareThirdPlaced(thirdTeams []*TeamStats) {
sort.Slice(thirdTeams, func(i, j int) bool {
ti, tj := thirdTeams[i], thirdTeams[j]
// 1. Points
if ti.Points != tj.Points {
return ti.Points > tj.Points
}
// 2. Goal Difference
if ti.GoalDifference != tj.GoalDifference {
return ti.GoalDifference > tj.GoalDifference
}
// 3. Goals Scored
if ti.GoalsScored != tj.GoalsScored {
return ti.GoalsScored > tj.GoalsScored
}
// 4. No fallback (retains the shuffled input order to randomize tie-breaks in Monte Carlo)
return false
})
}
+211
View File
@@ -0,0 +1,211 @@
package football
import (
"fmt"
"net/http"
"toolbox/pkg/base"
"toolbox/pkg/football/logic"
"github.com/gin-gonic/gin"
)
type footballTool struct{}
func init() {
base.Register(&footballTool{})
}
func (t *footballTool) ID() string { return "football" }
func (t *footballTool) Name() string { return "足球概率模拟" }
func (t *footballTool) Description() string {
return "使用蒙特卡洛算法模拟2026年美加墨世界杯小组赛阶段的出线概率"
}
func (t *footballTool) Emoji() string { return "⚽" }
func (t *footballTool) Init() error {
fmt.Println("Initializing Football Tool...")
return nil
}
func (t *footballTool) RegisterRoutes(r *gin.RouterGroup) {
r.GET("/defaults", t.handleDefaults)
r.POST("/simulate", t.handleSimulate)
}
type SimulateRequest struct {
Groups map[string][]string `json:"groups"`
CompletedMatches []logic.Match `json:"completed_matches"`
ScoreOutcomes []logic.ScoreOutcome `json:"score_outcomes"`
Simulations int `json:"simulations"`
}
func (t *footballTool) handleDefaults(c *gin.Context) {
defaultGroups := map[string][]string{
"A": {"MEX", "RSA", "KOR", "CZE"},
"B": {"CAN", "QAT", "SUI", "BIH"},
"C": {"BRA", "MAR", "HAI", "SCO"},
"D": {"USA", "PAR", "AUS", "TUR"},
"E": {"GER", "CUW", "CIV", "ECU"},
"F": {"NED", "JPN", "TUN", "SWE"},
"G": {"BEL", "EGY", "IRN", "NZL"},
"H": {"ESP", "KSA", "URU", "CPV"},
"I": {"FRA", "SEN", "IRQ", "NOR"},
"J": {"ARG", "ALG", "AUT", "JOR"},
"K": {"POR", "COL", "UZB", "COD"},
"L": {"ENG", "CRO", "GHA", "PAN"},
}
defaultMatches := []logic.Match{
// === ROUND 1 ===
{TeamA: "MEX", TeamB: "RSA", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group A
{TeamA: "KOR", TeamB: "CZE", IsCompleted: true, ScoreA: 2, ScoreB: 1}, // Group A
{TeamA: "CAN", TeamB: "BIH", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group B
{TeamA: "USA", TeamB: "PAR", IsCompleted: true, ScoreA: 4, ScoreB: 1}, // Group D
{TeamA: "QAT", TeamB: "SUI", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group B
{TeamA: "BRA", TeamB: "MAR", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group C
{TeamA: "SCO", TeamB: "HAI", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group C
{TeamA: "AUS", TeamB: "TUR", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group D
{TeamA: "GER", TeamB: "CUW", IsCompleted: true, ScoreA: 7, ScoreB: 1}, // Group E
{TeamA: "NED", TeamB: "JPN", IsCompleted: true, ScoreA: 2, ScoreB: 2}, // Group F
{TeamA: "CIV", TeamB: "ECU", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group E
{TeamA: "SWE", TeamB: "TUN", IsCompleted: true, ScoreA: 5, ScoreB: 1}, // Group F
{TeamA: "ESP", TeamB: "CPV", IsCompleted: true, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "BEL", TeamB: "EGY", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group G
{TeamA: "KSA", TeamB: "URU", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group H
{TeamA: "IRN", TeamB: "NZL", IsCompleted: true, ScoreA: 2, ScoreB: 2}, // Group G
{TeamA: "FRA", TeamB: "SEN", IsCompleted: true, ScoreA: 3, ScoreB: 1}, // Group I
{TeamA: "IRQ", TeamB: "NOR", IsCompleted: true, ScoreA: 1, ScoreB: 4}, // Group I
{TeamA: "ARG", TeamB: "ALG", IsCompleted: true, ScoreA: 3, ScoreB: 0}, // Group J
{TeamA: "AUT", TeamB: "JOR", IsCompleted: true, ScoreA: 3, ScoreB: 1}, // Group J
{TeamA: "POR", TeamB: "COD", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group K
{TeamA: "ENG", TeamB: "CRO", IsCompleted: true, ScoreA: 4, ScoreB: 2}, // Group L
{TeamA: "GHA", TeamB: "PAN", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group L
{TeamA: "UZB", TeamB: "COL", IsCompleted: true, ScoreA: 1, ScoreB: 3}, // Group K
// === ROUND 2 ===
{TeamA: "CZE", TeamB: "RSA", IsCompleted: true, ScoreA: 1, ScoreB: 1}, // Group A
{TeamA: "SUI", TeamB: "BIH", IsCompleted: true, ScoreA: 4, ScoreB: 1}, // Group B
{TeamA: "CAN", TeamB: "QAT", IsCompleted: true, ScoreA: 6, ScoreB: 0}, // Group B
{TeamA: "MEX", TeamB: "KOR", IsCompleted: true, ScoreA: 1, ScoreB: 0}, // Group A
{TeamA: "USA", TeamB: "AUS", IsCompleted: true, ScoreA: 2, ScoreB: 0}, // Group D
{TeamA: "SCO", TeamB: "MAR", IsCompleted: false, ScoreA: 0, ScoreB: 1}, // Group C
{TeamA: "BRA", TeamB: "HAI", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
{TeamA: "TUR", TeamB: "PAR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
{TeamA: "NED", TeamB: "SWE", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
{TeamA: "GER", TeamB: "CIV", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
{TeamA: "ECU", TeamB: "CUW", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
{TeamA: "TUN", TeamB: "JPN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
{TeamA: "ESP", TeamB: "KSA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "BEL", TeamB: "IRN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
{TeamA: "URU", TeamB: "CPV", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "NZL", TeamB: "EGY", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
{TeamA: "ARG", TeamB: "AUT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
{TeamA: "FRA", TeamB: "IRQ", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
{TeamA: "NOR", TeamB: "SEN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
{TeamA: "JOR", TeamB: "ALG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
{TeamA: "POR", TeamB: "UZB", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
{TeamA: "ENG", TeamB: "GHA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
{TeamA: "PAN", TeamB: "CRO", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
{TeamA: "COL", TeamB: "COD", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
// === ROUND 3 ===
{TeamA: "SUI", TeamB: "CAN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group B
{TeamA: "BIH", TeamB: "QAT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group B
{TeamA: "SCO", TeamB: "BRA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
{TeamA: "MAR", TeamB: "HAI", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group C
{TeamA: "CZE", TeamB: "MEX", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group A
{TeamA: "RSA", TeamB: "KOR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group A
{TeamA: "CUW", TeamB: "CIV", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
{TeamA: "ECU", TeamB: "GER", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group E
{TeamA: "JPN", TeamB: "SWE", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
{TeamA: "TUN", TeamB: "NED", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group F
{TeamA: "TUR", TeamB: "USA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
{TeamA: "PAR", TeamB: "AUS", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group D
{TeamA: "NOR", TeamB: "FRA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
{TeamA: "SEN", TeamB: "IRQ", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group I
{TeamA: "CPV", TeamB: "KSA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "URU", TeamB: "ESP", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group H
{TeamA: "EGY", TeamB: "IRN", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
{TeamA: "NZL", TeamB: "BEL", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group G
{TeamA: "PAN", TeamB: "ENG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
{TeamA: "CRO", TeamB: "GHA", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group L
{TeamA: "COL", TeamB: "POR", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
{TeamA: "COD", TeamB: "UZB", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group K
{TeamA: "ALG", TeamB: "AUT", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
{TeamA: "JOR", TeamB: "ARG", IsCompleted: false, ScoreA: 0, ScoreB: 0}, // Group J
}
c.JSON(http.StatusOK, gin.H{
"groups": defaultGroups,
"completed_matches": defaultMatches,
"score_outcomes": logic.DefaultScoreOutcomes(),
})
}
func (t *footballTool) handleSimulate(c *gin.Context) {
var req SimulateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.Groups) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Groups map cannot be empty"})
return
}
// Reconstruct Teams
var teams []logic.Team
for grp, grpTeams := range req.Groups {
for _, teamName := range grpTeams {
teams = append(teams, logic.Team{Name: teamName, Group: grp})
}
}
// Generate all group matches (completed + uncompleted)
var allMatches []logic.Match
for _, grpTeams := range req.Groups {
for i := 0; i < len(grpTeams); i++ {
for j := i + 1; j < len(grpTeams); j++ {
tA := grpTeams[i]
tB := grpTeams[j]
// Check if this match is completed in input
isCompleted := false
scoreA, scoreB := 0, 0
for _, cm := range req.CompletedMatches {
if !cm.IsCompleted {
continue
}
if cm.TeamA == tA && cm.TeamB == tB {
isCompleted = true
scoreA = cm.ScoreA
scoreB = cm.ScoreB
break
} else if cm.TeamA == tB && cm.TeamB == tA {
isCompleted = true
scoreA = cm.ScoreB
scoreB = cm.ScoreA
break
}
}
allMatches = append(allMatches, logic.Match{
TeamA: tA,
TeamB: tB,
IsCompleted: isCompleted,
ScoreA: scoreA,
ScoreB: scoreB,
})
}
}
}
sims := req.Simulations
if sims <= 0 {
sims = 50000
}
results := logic.RunMonteCarlo(teams, allMatches, req.ScoreOutcomes, sims)
c.JSON(http.StatusOK, results)
}
+87
View File
@@ -0,0 +1,87 @@
package football
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"toolbox/pkg/football/logic"
"github.com/gin-gonic/gin"
)
func TestFootballToolRoutes(t *testing.T) {
// Set Gin to test mode
gin.SetMode(gin.TestMode)
// Create test router and register routes
r := gin.New()
api := r.Group("/api/football")
tool := &footballTool{}
tool.RegisterRoutes(api)
// 1. Test /api/football/defaults
reqDefaults, _ := http.NewRequest(http.MethodGet, "/api/football/defaults", nil)
respDefaults := httptest.NewRecorder()
r.ServeHTTP(respDefaults, reqDefaults)
if respDefaults.Code != http.StatusOK {
t.Fatalf("expected status 200 OK for defaults, got %d", respDefaults.Code)
}
var defaults struct {
Groups map[string][]string `json:"groups"`
CompletedMatches []logic.Match `json:"completed_matches"`
ScoreOutcomes []logic.ScoreOutcome `json:"score_outcomes"`
}
if err := json.Unmarshal(respDefaults.Body.Bytes(), &defaults); err != nil {
t.Fatalf("failed to parse defaults JSON response: %v", err)
}
if len(defaults.Groups) != 12 {
t.Errorf("expected 12 groups, got %d", len(defaults.Groups))
}
if len(defaults.CompletedMatches) == 0 {
t.Errorf("expected completed matches in default configuration")
}
// 2. Test /api/football/simulate
simReq := SimulateRequest{
Groups: defaults.Groups,
CompletedMatches: defaults.CompletedMatches,
ScoreOutcomes: defaults.ScoreOutcomes,
Simulations: 100, // run a small number for fast test
}
reqBody, _ := json.Marshal(simReq)
reqSim, _ := http.NewRequest(http.MethodPost, "/api/football/simulate", bytes.NewBuffer(reqBody))
reqSim.Header.Set("Content-Type", "application/json")
respSim := httptest.NewRecorder()
r.ServeHTTP(respSim, reqSim)
if respSim.Code != http.StatusOK {
t.Fatalf("expected status 200 OK for simulate, got %d. Body: %s", respSim.Code, respSim.Body.String())
}
var results []logic.TeamProbability
if err := json.Unmarshal(respSim.Body.Bytes(), &results); err != nil {
t.Fatalf("failed to parse simulate JSON response: %v", err)
}
if len(results) != 48 {
t.Errorf("expected 48 team probabilities, got %d", len(results))
}
// Sum of TotalQualProb across all 48 teams should be exactly 32.0
var sum float64
for _, r := range results {
sum += r.TotalQualProb
}
// Because of floating point representation it should be extremely close to 32.0
if sum < 31.9 || sum > 32.1 {
t.Errorf("expected sum of probabilities to be 32.0, got %f", sum)
}
}
+186 -65
View File
@@ -7,7 +7,6 @@ import (
"regexp"
"strconv"
"strings"
"time"
"toolbox/pkg/learnnumber/data"
"github.com/signintech/gopdf"
@@ -33,15 +32,20 @@ type PathResult struct {
var reSVGToken = regexp.MustCompile(`[a-zA-Z]|-?\d+\.?\d*`)
func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
rand.Seed(time.Now().UnixNano())
pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89}
if req.PaperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
if req.PaperSize == "Letter" {
rect = gopdf.Rect{W: 612, H: 792}
}
pdf.Start(gopdf.Config{PageSize: rect})
// 硬性限制:页数 1-10 页,防止资源耗尽
if req.PageCount < 1 { req.PageCount = 1 }
if req.PageCount > 10 { req.PageCount = 10 }
if req.PageCount < 1 {
req.PageCount = 1
}
if req.PageCount > 10 {
req.PageCount = 10
}
for i := 0; i < req.PageCount; i++ {
pdf.AddPage()
@@ -57,20 +61,35 @@ func GenerateCountingPDF(req CountingRequest) ([]byte, error) {
func drawProblemV4(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64) {
margin := 40.0
boxW, boxH := (pW-2*margin)*0.7, pH-60.0
xBase, yBase := margin, startY + 30.0
xBase, yBase := margin, startY+30.0
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(2.5)
pdf.SetStrokeColor(0, 0, 0)
pdf.SetLineWidth(2.5)
pdf.RectFromUpperLeft(xBase, yBase, boxW, boxH)
catIcons := data.IconCategories[req.Category]
if len(catIcons) == 0 { catIcons = data.IconCategories["shapes"] }
if len(catIcons) == 0 {
catIcons = data.IconCategories["shapes"]
}
numTypes := req.IconTypes
if numTypes < 1 { numTypes = 1 }; if numTypes > 6 { numTypes = 6 }
if numTypes > len(catIcons) { numTypes = len(catIcons) }
if numTypes < 1 {
numTypes = 1
}
if numTypes > 6 {
numTypes = 6
}
if numTypes > len(catIcons) {
numTypes = len(catIcons)
}
total := req.TotalCount
if total < numTypes { total = numTypes }; if total > 30 { total = 30 }
if total < numTypes {
total = numTypes
}
if total > 30 {
total = 30
}
allIconsPerm := rand.Perm(len(catIcons))
var selectedIcons []data.Icon
@@ -87,49 +106,80 @@ func drawProblemV4(pdf *gopdf.GoPdf, req CountingRequest, startY, pW, pH float64
}
avgRadius := math.Sqrt((boxW * boxH * 0.22) / (float64(total) * math.Pi))
if avgRadius > 35.0 { avgRadius = 35.0 }
if avgRadius > 35.0 {
avgRadius = 35.0
}
var placed []PlacedIcon
iconTypeIdx, currentInType := 0, 0
for i := 0; i < total; i++ {
scaleVar := 0.9 + rand.Float64()*0.2; r := avgRadius * scaleVar
scaleVar := 0.9 + rand.Float64()*0.2
r := avgRadius * scaleVar
for retry := 0; retry < 200; retry++ {
randX := xBase + r + 5 + rand.Float64()*(boxW-2*r-10)
randY := yBase + r + 5 + rand.Float64()*(boxH-2*r-10)
collision := false
for _, p := range placed {
if math.Sqrt(math.Pow(randX-p.X, 2)+math.Pow(randY-p.Y, 2)) < (r + p.Radius + 10.0) {
collision = true; break
}
}
for _, p := range placed {
dx := randX - p.X
dy := randY - p.Y
if math.Sqrt(dx*dx+dy*dy) < (r + p.Radius + 10.0) {
collision = true
break
}
}
if !collision {
drawSimpleIcon(pdf, selectedIcons[iconTypeIdx], randX, randY, r*2)
placed = append(placed, PlacedIcon{X: randX, Y: randY, Radius: r}); break
placed = append(placed, PlacedIcon{X: randX, Y: randY, Radius: r})
break
}
}
currentInType++; if currentInType >= counts[iconTypeIdx] { iconTypeIdx++; currentInType = 0 }
currentInType++
if currentInType >= counts[iconTypeIdx] {
iconTypeIdx++
currentInType = 0
}
}
legendX, legendStepY := xBase+boxW+20.0, boxH/float64(numTypes+1)
for i := 0; i < numTypes; i++ {
lY := yBase + float64(i+1)*legendStepY
drawSimpleIcon(pdf, selectedIcons[i], legendX+25, lY, 35)
pdf.SetStrokeColor(150, 150, 150); pdf.SetLineWidth(1.0); pdf.RectFromUpperLeft(legendX+60, lY-15, 35, 35)
pdf.SetStrokeColor(150, 150, 150)
pdf.SetLineWidth(1.0)
pdf.RectFromUpperLeft(legendX+60, lY-15, 35, 35)
}
}
func drawSimpleIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
rawResults := parseMultiPath(icon, 1.0, 0, 0)
if len(rawResults) == 0 { return }
var minX, minY, maxX, maxY float64 = 1e9, 1e9, -1e9, -1e9
if len(rawResults) == 0 {
return
}
var minX, minY, maxX, maxY = 1e9, 1e9, -1e9, -1e9
for _, res := range rawResults {
for _, p := range res.Points {
if p.X < minX { minX = p.X }; if p.X > maxX { maxX = p.X }
if p.Y < minY { minY = p.Y }; if p.Y > maxY { maxY = p.Y }
if p.X < minX {
minX = p.X
}
if p.X > maxX {
maxX = p.X
}
if p.Y < minY {
minY = p.Y
}
if p.Y > maxY {
maxY = p.Y
}
}
}
curW, curH := maxX-minX, maxY-minY
if curW <= 0 { curW = 1 }; if curH <= 0 { curH = 1 }
if curW <= 0 {
curW = 1
}
if curH <= 0 {
curH = 1
}
scale := size / math.Max(curW, curH)
ox, oy := cX-(curW*scale)/2-minX*scale, cY-(curH*scale)/2-minY*scale
@@ -141,10 +191,16 @@ func drawSimpleIcon(pdf *gopdf.GoPdf, icon data.Icon, cX, cY, size float64) {
}
for _, res := range rawResults {
scaledPts := make([]gopdf.Point, len(res.Points))
for i, p := range res.Points { scaledPts[i] = gopdf.Point{X: ox + p.X*scale, Y: oy + p.Y*scale} }
for i, p := range res.Points {
scaledPts[i] = gopdf.Point{X: ox + p.X*scale, Y: oy + p.Y*scale}
}
if len(scaledPts) > 1 {
if res.Closed { pdf.Polygon(scaledPts, "D") } else {
for j := 0; j < len(scaledPts)-1; j++ { pdf.Line(scaledPts[j].X, scaledPts[j].Y, scaledPts[j+1].X, scaledPts[j+1].Y) }
if res.Closed {
pdf.Polygon(scaledPts, "D")
} else {
for j := 0; j < len(scaledPts)-1; j++ {
pdf.Line(scaledPts[j].X, scaledPts[j].Y, scaledPts[j+1].X, scaledPts[j+1].Y)
}
}
}
}
@@ -163,57 +219,122 @@ func parseMultiPath(icon data.Icon, scale, ox, oy float64) []PathResult {
if (token[0] >= 'a' && token[0] <= 'z') || (token[0] >= 'A' && token[0] <= 'Z') {
cmd := strings.ToUpper(token)
if cmd == "M" {
if len(pts) > 0 { results = append(results, PathResult{Points: pts, Closed: false}); pts = nil }
if len(pts) > 0 {
results = append(results, PathResult{Points: pts, Closed: false})
pts = nil
}
} else if cmd == "Z" {
if len(pts) > 0 { results = append(results, PathResult{Points: pts, Closed: true}); pts = nil }
lx, ly = startX, startY; i++; continue
if len(pts) > 0 {
results = append(results, PathResult{Points: pts, Closed: true})
pts = nil
}
lx, ly = startX, startY
i++
continue
}
currentCmd = cmd; isRel = (token[0] >= 'a' && token[0] <= 'z'); i++
currentCmd = cmd
isRel = (token[0] >= 'a' && token[0] <= 'z')
i++
continue
}
switch currentCmd {
case "M", "L":
if i+1 >= len(tokens) { i = len(tokens); break }
x, _ := strconv.ParseFloat(tokens[i], 64); y, _ := strconv.ParseFloat(tokens[i+1], 64)
if isRel { x += lx; y += ly }
if currentCmd == "M" { startX, startY = x, y }
if i+1 >= len(tokens) {
i = len(tokens)
break
}
x, _ := strconv.ParseFloat(tokens[i], 64)
y, _ := strconv.ParseFloat(tokens[i+1], 64)
if isRel {
x += lx
y += ly
}
if currentCmd == "M" {
startX, startY = x, y
}
lx, ly = x, y
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale}); i += 2
if currentCmd == "M" { currentCmd = "L" }
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
i += 2
if currentCmd == "M" {
currentCmd = "L"
}
case "H":
x, _ := strconv.ParseFloat(tokens[i], 64); if isRel { x += lx }; lx = x
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + ly*scale}); i++
x, _ := strconv.ParseFloat(tokens[i], 64)
if isRel {
x += lx
}
lx = x
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + ly*scale})
i++
case "V":
y, _ := strconv.ParseFloat(tokens[i], 64); if isRel { y += ly }; ly = y
pts = append(pts, gopdf.Point{X: ox + lx*scale, Y: oy + y*scale}); i++
y, _ := strconv.ParseFloat(tokens[i], 64)
if isRel {
y += ly
}
ly = y
pts = append(pts, gopdf.Point{X: ox + lx*scale, Y: oy + y*scale})
i++
case "C":
if i+5 >= len(tokens) { i = len(tokens); break }
x1, _ := strconv.ParseFloat(tokens[i], 64); y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x2, _ := strconv.ParseFloat(tokens[i+2], 64); y2, _ := strconv.ParseFloat(tokens[i+3], 64)
x, _ := strconv.ParseFloat(tokens[i+4], 64); y, _ := strconv.ParseFloat(tokens[i+5], 64)
if isRel { x1 += lx; y1 += ly; x2 += lx; y2 += ly; x += lx; y += ly }
for t := 0.2; t <= 1.0; t += 0.2 {
tx := math.Pow(1-t, 3)*lx + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
ty := math.Pow(1-t, 3)*ly + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
if i+5 >= len(tokens) {
i = len(tokens)
break
}
lx, ly = x, y; i += 6
x1, _ := strconv.ParseFloat(tokens[i], 64)
y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x2, _ := strconv.ParseFloat(tokens[i+2], 64)
y2, _ := strconv.ParseFloat(tokens[i+3], 64)
x, _ := strconv.ParseFloat(tokens[i+4], 64)
y, _ := strconv.ParseFloat(tokens[i+5], 64)
if isRel {
x1 += lx
y1 += ly
x2 += lx
y2 += ly
x += lx
y += ly
}
for t := 0.2; t <= 1.0; t += 0.2 {
invT := 1 - t
tx := invT*invT*invT*lx + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
ty := invT*invT*invT*ly + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
}
lx, ly = x, y
i += 6
case "Q":
if i+3 >= len(tokens) { i = len(tokens); break }
x1, _ := strconv.ParseFloat(tokens[i], 64); y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x, _ := strconv.ParseFloat(tokens[i+2], 64); y, _ := strconv.ParseFloat(tokens[i+3], 64)
if isRel { x1 += lx; y1 += ly; x += lx; y += ly }
for t := 0.25; t <= 1.0; t += 0.25 {
tx := math.Pow(1-t, 2)*lx + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
ty := math.Pow(1-t, 2)*ly + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
if i+3 >= len(tokens) {
i = len(tokens)
break
}
lx, ly = x, y; i += 4
case "A": i += 7
default: i++
x1, _ := strconv.ParseFloat(tokens[i], 64)
y1, _ := strconv.ParseFloat(tokens[i+1], 64)
x, _ := strconv.ParseFloat(tokens[i+2], 64)
y, _ := strconv.ParseFloat(tokens[i+3], 64)
if isRel {
x1 += lx
y1 += ly
x += lx
y += ly
}
for t := 0.25; t <= 1.0; t += 0.25 {
invT := 1 - t
tx := invT*invT*lx + 2*invT*t*x1 + t*t*x
ty := invT*invT*ly + 2*invT*t*y1 + t*t*y
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty*scale})
}
lx, ly = x, y
i += 4
case "A":
i += 7
default:
i++
}
}
if len(pts) > 0 { results = append(results, PathResult{Points: pts, Closed: false}) }
if len(pts) > 0 {
results = append(results, PathResult{Points: pts, Closed: false})
}
}
return results
}
+70 -27
View File
@@ -17,7 +17,7 @@ type WritingRequest struct {
}
type WritingNode struct {
X, Y, R float64
X, Y, R float64
AngleA, AngleB float64
BulgeR, Dist float64
Number int
@@ -26,11 +26,15 @@ type WritingNode struct {
func GenerateWritingPDF(req WritingRequest) ([]byte, error) {
pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89}
if req.PaperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
if req.PaperSize == "Letter" {
rect = gopdf.Rect{W: 612, H: 792}
}
pdf.Start(gopdf.Config{PageSize: rect})
err := pdf.AddTTFFontData("basic", data.FontContent)
if err != nil { return nil, err }
if err != nil {
return nil, err
}
currentNum := req.StartNum
// 只要还没达到 EndNum,就继续生成“整页”内容
@@ -63,10 +67,12 @@ func drawOneFullPage(pdf *gopdf.GoPdf, start int, pW, pH float64) int {
for {
placed := false
for retry := 0; retry < 1000; retry++ {
rx := xBase + dist + 20 + rand.Float64()*(boxW - 2*dist - 40)
ry := yBase + dist + 20 + rand.Float64()*(boxH - 2*dist - 40)
rx := xBase + dist + 20 + rand.Float64()*(boxW-2*dist-40)
ry := yBase + dist + 20 + rand.Float64()*(boxH-2*dist-40)
if isColliding(rx, ry, checkR, nodes) { continue }
if isColliding(rx, ry, checkR, nodes) {
continue
}
angleA := rand.Float64() * 2 * math.Pi
angleB := angleA + math.Pi + (rand.Float64()-0.5)*1.5
@@ -98,7 +104,9 @@ func drawOneFullPage(pdf *gopdf.GoPdf, start int, pW, pH float64) int {
}
func optimizeSpace(nodes []WritingNode, xBase, yBase, boxW, boxH, dist, checkR float64) {
if len(nodes) < 2 { return }
if len(nodes) < 2 {
return
}
for iter := 0; iter < 120; iter++ {
for i := range nodes {
bestX, bestY := nodes[i].X, nodes[i].Y
@@ -107,11 +115,16 @@ func optimizeSpace(nodes []WritingNode, xBase, yBase, boxW, boxH, dist, checkR f
moveAngle := rand.Float64() * 2 * math.Pi
nx := nodes[i].X + math.Cos(moveAngle)*5.0
ny := nodes[i].Y + math.Sin(moveAngle)*5.0
if nx-dist < xBase || nx+dist > xBase+boxW || ny-dist < yBase || ny+dist > yBase+boxH { continue }
if isColliding(nx, ny, checkR, nodes[:i]) || isColliding(nx, ny, checkR, nodes[i+1:]) { continue }
if nx-dist < xBase || nx+dist > xBase+boxW || ny-dist < yBase || ny+dist > yBase+boxH {
continue
}
if isColliding(nx, ny, checkR, nodes[:i]) || isColliding(nx, ny, checkR, nodes[i+1:]) {
continue
}
newMinDist := calcMinDist(nx, ny, i, nodes)
if newMinDist > maxMinDist {
maxMinDist = newMinDist; bestX, bestY = nx, ny
maxMinDist = newMinDist
bestX, bestY = nx, ny
}
}
nodes[i].X, nodes[i].Y = bestX, bestY
@@ -121,8 +134,12 @@ func optimizeSpace(nodes []WritingNode, xBase, yBase, boxW, boxH, dist, checkR f
func isColliding(x, y, r float64, existing []WritingNode) bool {
for _, e := range existing {
d := math.Sqrt(math.Pow(x-e.X, 2) + math.Pow(y-e.Y, 2))
if d < (r + e.R*1.3 + 30.0) { return true }
dx := x - e.X
dy := y - e.Y
d := math.Sqrt(dx*dx + dy*dy)
if d < (r + e.R*1.3 + 30.0) {
return true
}
}
return false
}
@@ -130,9 +147,15 @@ func isColliding(x, y, r float64, existing []WritingNode) bool {
func calcMinDist(x, y float64, idx int, nodes []WritingNode) float64 {
minD := 10000.0
for i, n := range nodes {
if i == idx { continue }
d := math.Sqrt(math.Pow(x-n.X, 2) + math.Pow(y-n.Y, 2))
if d < minD { minD = d }
if i == idx {
continue
}
dx := x - n.X
dy := y - n.Y
d := math.Sqrt(dx*dx + dy*dy)
if d < minD {
minD = d
}
}
return minD
}
@@ -140,39 +163,59 @@ func calcMinDist(x, y float64, idx int, nodes []WritingNode) float64 {
func getEdgePoint(x, y, r, angle float64, isCircle bool) (float64, float64) {
gap := 2.0
er := r + gap
if isCircle { return x + math.Cos(angle)*er, y + math.Sin(angle)*er }
if isCircle {
return x + math.Cos(angle)*er, y + math.Sin(angle)*er
}
absCos, absSin := math.Abs(math.Cos(angle)), math.Abs(math.Sin(angle))
var d float64
if absCos > absSin { d = er / absCos } else { d = er / absSin }
if absCos > absSin {
d = er / absCos
} else {
d = er / absSin
}
return x + math.Cos(angle)*d, y + math.Sin(angle)*d
}
func drawCenteredText(pdf *gopdf.GoPdf, cx, cy float64, text string, fontSize float64) {
pdf.SetFont("basic", "", fontSize)
_ = pdf.SetFont("basic", "", fontSize)
tw, _ := pdf.MeasureTextWidth(text)
pdf.SetXY(cx - tw/2, cy + fontSize*0.38)
pdf.Text(text)
pdf.SetXY(cx-tw/2, cy+fontSize*0.38)
_ = pdf.Text(text)
}
func drawFullNode(pdf *gopdf.GoPdf, n WritingNode) {
isOdd := n.Number%2 != 0
pdf.SetStrokeColor(0, 0, 0); pdf.SetLineWidth(1.8)
if isOdd { pdf.Oval(n.X-n.R, n.Y-n.R, n.X+n.R, n.Y+n.R) } else { pdf.RectFromUpperLeft(n.X-n.R, n.Y-n.R, n.R*2, n.R*2) }
pdf.SetStrokeColor(0, 0, 0)
pdf.SetLineWidth(1.8)
if isOdd {
pdf.Oval(n.X-n.R, n.Y-n.R, n.X+n.R, n.Y+n.R)
} else {
pdf.RectFromUpperLeft(n.X-n.R, n.Y-n.R, n.R*2, n.R*2)
}
pdf.SetTextColor(220, 220, 220)
drawCenteredText(pdf, n.X, n.Y, strconv.Itoa(n.Number), n.R*1.35)
pdf.SetStrokeColor(180, 180, 180); pdf.SetLineWidth(1.0)
ax, ay := n.X + math.Cos(n.AngleA)*n.Dist, n.Y + math.Sin(n.AngleA)*n.Dist
pdf.SetStrokeColor(180, 180, 180)
pdf.SetLineWidth(1.0)
ax, ay := n.X+math.Cos(n.AngleA)*n.Dist, n.Y+math.Sin(n.AngleA)*n.Dist
lx1, ly1 := getEdgePoint(n.X, n.Y, n.R, n.AngleA, isOdd)
lx2, ly2 := getEdgePoint(ax, ay, n.BulgeR, n.AngleA+math.Pi, !isOdd)
pdf.Line(lx1, ly1, lx2, ly2)
if isOdd { pdf.RectFromUpperLeft(ax-n.BulgeR, ay-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(ax-n.BulgeR, ay-n.BulgeR, ax+n.BulgeR, ay+n.BulgeR) }
if isOdd {
pdf.RectFromUpperLeft(ax-n.BulgeR, ay-n.BulgeR, n.BulgeR*2, n.BulgeR*2)
} else {
pdf.Oval(ax-n.BulgeR, ay-n.BulgeR, ax+n.BulgeR, ay+n.BulgeR)
}
bx, by := n.X + math.Cos(n.AngleB)*n.Dist, n.Y + math.Sin(n.AngleB)*n.Dist
bx, by := n.X+math.Cos(n.AngleB)*n.Dist, n.Y+math.Sin(n.AngleB)*n.Dist
lx3, ly3 := getEdgePoint(n.X, n.Y, n.R, n.AngleB, isOdd)
lx4, ly4 := getEdgePoint(bx, by, n.BulgeR, n.AngleB+math.Pi, !isOdd)
pdf.Line(lx3, ly3, lx4, ly4)
if isOdd { pdf.RectFromUpperLeft(bx-n.BulgeR, by-n.BulgeR, n.BulgeR*2, n.BulgeR*2) } else { pdf.Oval(bx-n.BulgeR, by-n.BulgeR, bx+n.BulgeR, by+n.BulgeR) }
if isOdd {
pdf.RectFromUpperLeft(bx-n.BulgeR, by-n.BulgeR, n.BulgeR*2, n.BulgeR*2)
} else {
pdf.Oval(bx-n.BulgeR, by-n.BulgeR, bx+n.BulgeR, by+n.BulgeR)
}
pdf.SetTextColor(120, 120, 120)
drawCenteredText(pdf, bx, by, strconv.Itoa(n.Number+1), math.Max(7, n.BulgeR*1.2))
+6 -4
View File
@@ -16,10 +16,12 @@ func init() {
base.Register(&learnNumberTool{})
}
func (t *learnNumberTool) ID() string { return "learn-number" }
func (t *learnNumberTool) Name() string { return "幼儿数学助手" }
func (t *learnNumberTool) Description() string { return "包含数图形、基础加减法等趣味数学练习" }
func (t *learnNumberTool) Emoji() string { return "🔢" }
func (t *learnNumberTool) ID() string { return "learn-number" }
func (t *learnNumberTool) Name() string { return "幼儿数学助手" }
func (t *learnNumberTool) Description() string {
return "包含数图形、基础加减法等趣味数学练习"
}
func (t *learnNumberTool) Emoji() string { return "🔢" }
func (t *learnNumberTool) Init() error {
fmt.Println("Initializing Learn-Number tool...")
+248 -92
View File
@@ -16,14 +16,16 @@ var (
sfntMap = make(map[string]*sfnt.Font)
)
func SetFontData(data []byte) { embeddedFont = data }
func RegisterFontPath(id, path string) { fontMap[id] = path }
func SetFontData(data []byte) { embeddedFont = data }
func RegisterFontPath(id, path string) { fontMap[id] = path }
func RegisterFontSFNT(id string, f *sfnt.Font) { sfntMap[id] = f }
// HasGlyph 检查字体是否包含某个字符
func HasGlyph(fontId string, char rune) bool {
f, ok := sfntMap[fontId]
if !ok { return false }
if !ok {
return false
}
var buffer sfnt.Buffer
idx, err := f.GlyphIndex(&buffer, char)
return err == nil && idx != 0
@@ -33,10 +35,14 @@ func HasGlyph(fontId string, char rune) bool {
func GeneratePDFExtended(chars []HanziData, mode, paperSize, fontId string) ([]byte, error) {
pdf := &gopdf.GoPdf{}
rect := gopdf.Rect{W: 595.28, H: 841.89}
if paperSize == "Letter" { rect = gopdf.Rect{W: 612, H: 792} }
if paperSize == "Letter" {
rect = gopdf.Rect{W: 612, H: 792}
}
pdf.Start(gopdf.Config{PageSize: rect})
if len(embeddedFont) > 0 { _ = pdf.AddTTFFontData("font", embeddedFont) }
if len(embeddedFont) > 0 {
_ = pdf.AddTTFFontData("font", embeddedFont)
}
// 注册所有可用字体
for id, path := range fontMap {
@@ -58,13 +64,17 @@ func GeneratePDFExtended(chars []HanziData, mode, paperSize, fontId string) ([]b
}
func addTeachingPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
cols := 2; gap, margin := 17.0, 40.0
cols := 2
gap, margin := 17.0, 40.0
size := math.Min((pW-2*margin-gap)/2, (pH-2*margin-2*gap)/3)
totalW, totalH := 2*size+gap, 3*size+2*gap
marginX, marginY := (pW-totalW)/2, (pH-totalH)/2
for i := 0; i < len(chars); i += 6 {
pdf.AddPage()
end := i + 6; if end > len(chars) { end = len(chars) }
end := i + 6
if end > len(chars) {
end = len(chars)
}
for idx, data := range chars[i:end] {
drawCharacter(pdf, data, marginX+float64(idx%cols)*(size+gap), marginY+float64(idx/cols)*(size+gap), size, flipY, len(data.Strokes), true)
}
@@ -72,43 +82,73 @@ func addTeachingPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH fl
}
func addStepPages(pdf *gopdf.GoPdf, chars []HanziData, flipY bool, pW, pH float64) {
cols, margin := 9, 30.0; size := (pW - 2*margin) / float64(cols)
pdf.AddPage(); currY := margin
cols, margin := 9, 30.0
size := (pW - 2*margin) / float64(cols)
pdf.AddPage()
currY := margin
for _, data := range chars {
numS := len(data.Strokes); rowsN := 1; if numS > 8 { rowsN = 1 + (numS - 8 + 7) / 8 }
if currY + float64(rowsN)*size > pH - margin { pdf.AddPage(); currY = margin }
totalG := rowsN * cols; strokeC := 0
numS := len(data.Strokes)
rowsN := 1
if numS > 8 {
rowsN = 1 + (numS-8+7)/8
}
if currY+float64(rowsN)*size > pH-margin {
pdf.AddPage()
currY = margin
}
totalG := rowsN * cols
strokeC := 0
for gIdx := 0; gIdx < totalG; gIdx++ {
r, c := gIdx/cols, gIdx%cols; x, y := margin+float64(c)*size, currY+float64(r)*size
if r == 0 && c == 0 { drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), false); strokeC = 1
} else if r > 0 && c == 0 { drawMiZiGe(pdf, x, y, size)
} else if strokeC <= numS { drawStepBox(pdf, data, x, y, size, flipY, strokeC); strokeC++
} else { drawMiZiGe(pdf, x, y, size) }
r, c := gIdx/cols, gIdx%cols
x, y := margin+float64(c)*size, currY+float64(r)*size
if r == 0 && c == 0 {
drawCharacter(pdf, data, x, y, size, flipY, len(data.Strokes), false)
strokeC = 1
} else if r > 0 && c == 0 {
drawMiZiGe(pdf, x, y, size)
} else if strokeC <= numS {
drawStepBox(pdf, data, x, y, size, flipY, strokeC)
strokeC++
} else {
drawMiZiGe(pdf, x, y, size)
}
}
currY += float64(rowsN) * size
}
}
func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, targetFont string) {
margin := 40.0; cols, rows := 10, 15
margin := 40.0
cols, rows := 10, 15
colW, rowH := (pW-2*margin)/float64(cols), (pH-2*margin)/float64(rows)
fontSize := colW * 0.75
pdf.AddPage(); drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
pdf.AddPage()
drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
cIdx, rIdx := 0, 0
for _, data := range chars {
if data.Character == "\n" {
cIdx++; rIdx = 0
if cIdx >= cols { pdf.AddPage(); cIdx = 0; drawManuscriptGrid(pdf, pW, pH, margin, cols, colW) }
cIdx++
rIdx = 0
if cIdx >= cols {
pdf.AddPage()
cIdx = 0
drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
}
continue
}
if rIdx >= rows {
cIdx++; rIdx = 0
if cIdx >= cols { pdf.AddPage(); cIdx = 0; drawManuscriptGrid(pdf, pW, pH, margin, cols, colW) }
cIdx++
rIdx = 0
if cIdx >= cols {
pdf.AddPage()
cIdx = 0
drawManuscriptGrid(pdf, pW, pH, margin, cols, colW)
}
}
x, y := pW - margin - float64(cIdx+1)*colW, margin + float64(rIdx)*rowH
x, y := pW-margin-float64(cIdx+1)*colW, margin+float64(rIdx)*rowH
charRune := []rune(data.Character)[0]
// 智能字体选择
@@ -118,12 +158,15 @@ func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, tar
if !HasGlyph(targetFont, charRune) {
// 尝试降级到字库最全的宋体或楷体
if HasGlyph("songti", charRune) {
renderFont = "songti"; isFallback = true
renderFont = "songti"
isFallback = true
} else if HasGlyph("kaiti", charRune) {
renderFont = "kaiti"; isFallback = true
renderFont = "kaiti"
isFallback = true
} else {
// 全部缺失,保留空白
rIdx++; continue
rIdx++
continue
}
}
@@ -133,12 +176,12 @@ func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, tar
smallSize := fontSize * 0.5
_ = pdf.SetFont(renderFont, "", smallSize)
// 计算右上角位置:X靠右,Y靠上
pdf.SetXY(x + colW - smallSize - 2, y + 2)
pdf.SetXY(x+colW-smallSize-2, y+2)
_ = pdf.Cell(nil, data.Character)
} else {
// 正常显示
_ = pdf.SetFont(renderFont, "", fontSize)
pdf.SetXY(x + (colW-fontSize*0.9)/2, y + (rowH-fontSize)/2)
pdf.SetXY(x+(colW-fontSize*0.9)/2, y+(rowH-fontSize)/2)
_ = pdf.Cell(nil, data.Character)
}
rIdx++
@@ -146,108 +189,221 @@ func addManuscriptPages(pdf *gopdf.GoPdf, chars []HanziData, pW, pH float64, tar
}
func drawManuscriptGrid(pdf *gopdf.GoPdf, pW, pH, margin float64, cols int, colW float64) {
pdf.SetStrokeColor(200, 0, 0); pdf.SetLineWidth(0.6)
for c := 0; c <= cols; c++ { x := pW - margin - float64(c)*colW; pdf.Line(x, margin, x, pH-margin) }
pdf.Line(margin, margin, pW-margin, margin); pdf.Line(margin, pH-margin, pW-margin, pH-margin)
pdf.SetStrokeColor(200, 0, 0)
pdf.SetLineWidth(0.6)
for c := 0; c <= cols; c++ {
x := pW - margin - float64(c)*colW
pdf.Line(x, margin, x, pH-margin)
}
pdf.Line(margin, margin, pW-margin, margin)
pdf.Line(margin, pH-margin, pW-margin, pH-margin)
}
func drawCharacter(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, strokeLimit int, showAnnotations bool) {
if showAnnotations { drawGrid(pdf, x, y, size) } else { drawMiZiGe(pdf, x, y, size) }
p, drawS := size*0.12, size-(size*0.12*2); scale := drawS/1024.0
if showAnnotations {
drawGrid(pdf, x, y, size)
} else {
drawMiZiGe(pdf, x, y, size)
}
p, drawS := size*0.12, size-(size*0.12*2)
scale := drawS / 1024.0
if strokeLimit == len(data.Strokes) {
if showAnnotations { pdf.SetFillColor(240, 240, 240) } else { pdf.SetFillColor(0, 0, 0) }
} else { pdf.SetFillColor(180, 180, 180) }
if showAnnotations {
pdf.SetFillColor(240, 240, 240)
} else {
pdf.SetFillColor(0, 0, 0)
}
} else {
pdf.SetFillColor(180, 180, 180)
}
for sIdx := 0; sIdx < len(data.Strokes); sIdx++ {
if sIdx >= strokeLimit && strokeLimit != len(data.Strokes) { break }
if sIdx >= strokeLimit && strokeLimit != len(data.Strokes) {
break
}
pts := parseSVGPath(data.Strokes[sIdx], scale, x+p, y+p, flipY)
if len(pts) > 2 { pdf.Polygon(pts, "F") }
if len(pts) > 2 {
pdf.Polygon(pts, "F")
}
}
if showAnnotations && strokeLimit == len(data.Strokes) {
fS := size * 0.035
_ = pdf.SetFont("font", "", fS)
for idx, median := range data.Medians {
mP := transformPoints(median, scale, x+p, y+p, flipY); if len(mP) < 2 { continue }
pdf.SetStrokeColor(255, 0, 0); pdf.SetLineWidth(0.6)
for j := 0; j < len(mP)-1; j++ { pdf.Line(mP[j].X, mP[j].Y, mP[j+1].X, mP[j+1].Y) }
pdf.SetFillColor(255, 0, 0); drawArrow(pdf, mP[len(mP)-1], mP[len(mP)-2], size*0.035)
r := size*0.025; dx, dy := mP[1].X-mP[0].X, mP[1].Y-mP[0].Y; dist := math.Sqrt(dx*dx+dy*dy); ux, uy := -1.0, -1.0
if dist > 0.001 { ux, uy = dx/dist, dy/dist }; cX, cY := mP[0].X-ux*r, mP[0].Y-uy*r
pdf.SetStrokeColor(255, 0, 0); pdf.SetLineWidth(0.5); pdf.Oval(cX-r, cY-r, cX+r, cY+r)
numS := strconv.Itoa(idx+1); tw := fS*0.6*float64(len(numS))/2; if len(numS) > 1 { tw = fS*0.5 }
pdf.SetFillColor(255, 0, 0); pdf.SetXY(cX-tw/2, cY-fS/2); _ = pdf.Cell(nil, numS)
mP := transformPoints(median, scale, x+p, y+p, flipY)
if len(mP) < 2 {
continue
}
pdf.SetStrokeColor(255, 0, 0)
pdf.SetLineWidth(0.6)
for j := 0; j < len(mP)-1; j++ {
pdf.Line(mP[j].X, mP[j].Y, mP[j+1].X, mP[j+1].Y)
}
pdf.SetFillColor(255, 0, 0)
drawArrow(pdf, mP[len(mP)-1], mP[len(mP)-2], size*0.035)
r := size * 0.025
dx, dy := mP[1].X-mP[0].X, mP[1].Y-mP[0].Y
dist := math.Sqrt(dx*dx + dy*dy)
ux, uy := -1.0, -1.0
if dist > 0.001 {
ux, uy = dx/dist, dy/dist
}
cX, cY := mP[0].X-ux*r, mP[0].Y-uy*r
pdf.SetStrokeColor(255, 0, 0)
pdf.SetLineWidth(0.5)
pdf.Oval(cX-r, cY-r, cX+r, cY+r)
numS := strconv.Itoa(idx + 1)
tw := fS * 0.6 * float64(len(numS)) / 2
if len(numS) > 1 {
tw = fS * 0.5
}
pdf.SetFillColor(255, 0, 0)
pdf.SetXY(cX-tw/2, cY-fS/2)
_ = pdf.Cell(nil, numS)
}
}
}
func drawStepBox(pdf *gopdf.GoPdf, data HanziData, x, y, size float64, flipY bool, limit int) {
drawMiZiGe(pdf, x, y, size); p, scale := size*0.12, (size-(size*0.12*2))/1024.0
pdf.SetFillColor(180, 180, 180); for i := 0; i < limit; i++ {
pts := parseSVGPath(data.Strokes[i], scale, x+p, y+p, flipY); if len(pts) > 2 { pdf.Polygon(pts, "F") }
drawMiZiGe(pdf, x, y, size)
p, scale := size*0.12, (size-(size*0.12*2))/1024.0
pdf.SetFillColor(180, 180, 180)
for i := 0; i < limit; i++ {
pts := parseSVGPath(data.Strokes[i], scale, x+p, y+p, flipY)
if len(pts) > 2 {
pdf.Polygon(pts, "F")
}
}
}
func drawGrid(pdf *gopdf.GoPdf, x, y, size float64) {
pdf.SetStrokeColor(220, 220, 220); pdf.SetLineWidth(0.4); pdf.RectFromUpperLeft(x, y, size, size)
mid := size/2; drawDashedLine(pdf, x, y+mid, x+size, y+mid); drawDashedLine(pdf, x+mid, y, x+mid, y+size)
pdf.SetStrokeColor(220, 220, 220)
pdf.SetLineWidth(0.4)
pdf.RectFromUpperLeft(x, y, size, size)
mid := size / 2
drawDashedLine(pdf, x, y+mid, x+size, y+mid)
drawDashedLine(pdf, x+mid, y, x+mid, y+size)
}
func drawMiZiGe(pdf *gopdf.GoPdf, x, y, size float64) {
pdf.SetStrokeColor(220, 220, 220); pdf.SetLineWidth(0.4); pdf.RectFromUpperLeft(x, y, size, size)
mid := size/2; drawDashedLine(pdf, x, y+mid, x+size, y+mid); drawDashedLine(pdf, x+mid, y, x+mid, y+size); drawDashedLine(pdf, x, y, x+size, y+size); drawDashedLine(pdf, x, y+size, x+size, y)
pdf.SetStrokeColor(220, 220, 220)
pdf.SetLineWidth(0.4)
pdf.RectFromUpperLeft(x, y, size, size)
mid := size / 2
drawDashedLine(pdf, x, y+mid, x+size, y+mid)
drawDashedLine(pdf, x+mid, y, x+mid, y+size)
drawDashedLine(pdf, x, y, x+size, y+size)
drawDashedLine(pdf, x, y+size, x+size, y)
}
func drawDashedLine(pdf *gopdf.GoPdf, x1, y1, x2, y2 float64) {
dash := 2.0; dx, dy := x2-x1, y2-y1; dist := math.Sqrt(dx*dx + dy*dy); if dist < 0.001 { return }
ux, uy := dx/dist, dy/dist; for i := 0.0; i < dist; i += dash * 2 { end := i + dash; if end > dist { end = dist }; pdf.Line(x1+ux*i, y1+uy*i, x1+ux*end, y1+uy*end) }
dash := 2.0
dx, dy := x2-x1, y2-y1
dist := math.Sqrt(dx*dx + dy*dy)
if dist < 0.001 {
return
}
ux, uy := dx/dist, dy/dist
for i := 0.0; i < dist; i += dash * 2 {
end := i + dash
if end > dist {
end = dist
}
pdf.Line(x1+ux*i, y1+uy*i, x1+ux*end, y1+uy*end)
}
}
func drawArrow(pdf *gopdf.GoPdf, target, prev gopdf.Point, length float64) {
angle := math.Atan2(target.Y-prev.Y, target.X-prev.X); arrowA := math.Pi/6
p1X := target.X+length*math.Cos(angle+math.Pi+arrowA); p1Y := target.Y+length*math.Sin(angle+math.Pi+arrowA)
p2X := target.X+length*math.Cos(angle+math.Pi-arrowA); p2Y := target.Y+length*math.Sin(angle+math.Pi-arrowA)
angle := math.Atan2(target.Y-prev.Y, target.X-prev.X)
arrowA := math.Pi / 6
p1X := target.X + length*math.Cos(angle+math.Pi+arrowA)
p1Y := target.Y + length*math.Sin(angle+math.Pi+arrowA)
p2X := target.X + length*math.Cos(angle+math.Pi-arrowA)
p2Y := target.Y + length*math.Sin(angle+math.Pi-arrowA)
pdf.Polygon([]gopdf.Point{target, {X: p1X, Y: p1Y}, {X: p2X, Y: p2Y}}, "F")
}
func transformPoints(pts []Point, scale, ox, oy float64, flipY bool) []gopdf.Point {
res := make([]gopdf.Point, len(pts)); for i, p := range pts { y := p[1]; if flipY { y = 1024-y }; res[i] = gopdf.Point{X: ox+p[0]*scale, Y: oy+y*scale} }
res := make([]gopdf.Point, len(pts))
for i, p := range pts {
y := p[1]
if flipY {
y = 1024 - y
}
res[i] = gopdf.Point{X: ox + p[0]*scale, Y: oy + y*scale}
}
return res
}
var reSVG = regexp.MustCompile(`([MLQCZ])|(-?\d+\.?\d*)`)
func parseSVGPath(path string, scale, ox, oy float64, flipY bool) []gopdf.Point {
var pts []gopdf.Point; matches := reSVG.FindAllStringSubmatch(path, -1); var lastX, lastY float64
for idx := 0; idx < len(matches); {
item := matches[idx][0]
if item == "M" || item == "L" {
if idx+2 < len(matches) {
x, _ := strconv.ParseFloat(matches[idx+1][0], 64); y, _ := strconv.ParseFloat(matches[idx+2][0], 64); lastX, lastY = x, y
if flipY { y = 1024 - y }; pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale}); idx += 3
} else { idx++ }
} else if item == "C" {
if idx+6 < len(matches) {
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64); y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64); y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
x, _ := strconv.ParseFloat(matches[idx+5][0], 64); y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
for t := 0.2; t <= 1.0; t += 0.2 {
tx := math.Pow(1-t, 3)*lastX + 3*math.Pow(1-t, 2)*t*x1 + 3*(1-t)*math.Pow(t, 2)*x2 + math.Pow(t, 3)*x
ty := math.Pow(1-t, 3)*lastY + 3*math.Pow(1-t, 2)*t*y1 + 3*(1-t)*math.Pow(t, 2)*y2 + math.Pow(t, 3)*y
ty_f := ty; if flipY { ty_f = 1024 - ty }; pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
var pts []gopdf.Point
matches := reSVG.FindAllStringSubmatch(path, -1)
var lastX, lastY float64
for idx := 0; idx < len(matches); {
item := matches[idx][0]
switch item {
case "M", "L":
if idx+2 < len(matches) {
x, _ := strconv.ParseFloat(matches[idx+1][0], 64)
y, _ := strconv.ParseFloat(matches[idx+2][0], 64)
lastX, lastY = x, y
if flipY {
y = 1024 - y
}
pts = append(pts, gopdf.Point{X: ox + x*scale, Y: oy + y*scale})
idx += 3
} else {
idx++
}
case "C":
if idx+6 < len(matches) {
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
x2, _ := strconv.ParseFloat(matches[idx+3][0], 64)
y2, _ := strconv.ParseFloat(matches[idx+4][0], 64)
x, _ := strconv.ParseFloat(matches[idx+5][0], 64)
y, _ := strconv.ParseFloat(matches[idx+6][0], 64)
for t := 0.2; t <= 1.0; t += 0.2 {
invT := 1 - t
tx := invT*invT*invT*lastX + 3*invT*invT*t*x1 + 3*invT*t*t*x2 + t*t*t*x
ty := invT*invT*invT*lastY + 3*invT*invT*t*y1 + 3*invT*t*t*y2 + t*t*t*y
ty_f := ty
if flipY {
ty_f = 1024 - ty
}
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
}
lastX, lastY = x, y
idx += 7
} else {
idx++
}
case "Q":
if idx+4 < len(matches) {
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64)
y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
x, _ := strconv.ParseFloat(matches[idx+3][0], 64)
y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
for t := 0.2; t <= 1.0; t += 0.2 {
invT := 1 - t
tx := invT*invT*lastX + 2*invT*t*x1 + t*t*x
ty := invT*invT*lastY + 2*invT*t*y1 + t*t*y
ty_f := ty
if flipY {
ty_f = 1024 - ty
}
pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
}
lastX, lastY = x, y
idx += 5
} else {
idx++
}
default:
idx++
}
lastX, lastY = x, y; idx += 7
} else { idx++ }
} else if item == "Q" {
if idx+4 < len(matches) {
x1, _ := strconv.ParseFloat(matches[idx+1][0], 64); y1, _ := strconv.ParseFloat(matches[idx+2][0], 64)
x, _ := strconv.ParseFloat(matches[idx+3][0], 64); y, _ := strconv.ParseFloat(matches[idx+4][0], 64)
for t := 0.2; t <= 1.0; t += 0.2 {
tx := math.Pow(1-t, 2)*lastX + 2*(1-t)*t*x1 + math.Pow(t, 2)*x
ty := math.Pow(1-t, 2)*lastY + 2*(1-t)*t*y1 + math.Pow(t, 2)*y
ty_f := ty; if flipY { ty_f = 1024 - ty }; pts = append(pts, gopdf.Point{X: ox + tx*scale, Y: oy + ty_f*scale})
}
lastX, lastY = x, y; idx += 5
} else { idx++ }
} else { idx++ }
}
}
return pts
}
+3 -3
View File
@@ -1,9 +1,9 @@
package logic
type HanziData struct {
Character string `json:"character"`
Strokes []string `json:"strokes"`
Medians [][]Point `json:"medians"`
Character string `json:"character"`
Strokes []string `json:"strokes"`
Medians [][]Point `json:"medians"`
}
type Point [2]float64
+31 -14
View File
@@ -28,10 +28,12 @@ func init() {
base.Register(&zitieTool{})
}
func (t *zitieTool) ID() string { return "zitie" }
func (t *zitieTool) Name() string { return "汉字字帖生成" }
func (t *zitieTool) Description() string { return "提供智能缺字处理和古风排版的专业字帖工具" }
func (t *zitieTool) Emoji() string { return "🎨" }
func (t *zitieTool) ID() string { return "zitie" }
func (t *zitieTool) Name() string { return "汉字字帖生成" }
func (t *zitieTool) Description() string {
return "提供智能缺字处理和古风排版的专业字帖工具"
}
func (t *zitieTool) Emoji() string { return "🎨" }
func (t *zitieTool) Init() error {
fmt.Println("Initializing Zitie tool with font check...")
@@ -44,15 +46,17 @@ func (t *zitieTool) Init() error {
logic.SetFontData(fontBytes)
fonts := map[string]string{
"kaiti": "data/kaiti.ttf",
"lishu": "data/lishu.ttf",
"xingshu": "data/xingshu.ttf",
"songti": "data/songti.ttf",
"kaiti": "data/kaiti.ttf",
"lishu": "data/lishu.ttf",
"xingshu": "data/xingshu.ttf",
"songti": "data/songti.ttf",
}
for id, src := range fonts {
bytes, err := fontFS.ReadFile(src)
if err != nil { continue }
if err != nil {
continue
}
// 1. 同步到磁盘用于 gopdf
tmpPath := filepath.Join(os.TempDir(), fmt.Sprintf("own_tools_%s.ttf", id))
@@ -108,7 +112,9 @@ func (t *zitieTool) handleManuscript(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.FontType == "" { req.FontType = "kaiti" }
if req.FontType == "" {
req.FontType = "kaiti"
}
data := t.filterChars(req.Chars, true)
t.generateAndResponse(c, data, "manuscript", req.PaperSize, req.FontType)
}
@@ -121,14 +127,25 @@ func (t *zitieTool) filterChars(input string, keepNewline bool) []logic.HanziDat
for _, r := range input {
charStr := string(r)
if r == '\n' {
if keepNewline { res = append(res, logic.HanziData{Character: "\n"}) }
if keepNewline {
res = append(res, logic.HanziData{Character: "\n"})
}
continue
}
if r == ' ' || r == '\r' || r == '\t' {
continue
}
if r == ' ' || r == '\r' || r == '\t' { continue }
isPunc := false
for _, p := range puncs { if r == p { isPunc = true; break } }
if isPunc { continue }
for _, p := range puncs {
if r == p {
isPunc = true
break
}
}
if isPunc {
continue
}
if hd, ok := t.allChars[charStr]; ok {
hd.Character = charStr