github.com/laof/lite-speed-test@v0.0.0-20230930011949-1f39b7037845/web/render/table.go (about)

     1  package render
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"encoding/csv"
     7  	"encoding/json"
     8  	"fmt"
     9  	"os"
    10  	"reflect"
    11  	"runtime"
    12  	"sort"
    13  	"strconv"
    14  	"time"
    15  
    16  	"github.com/laof/lite-speed-test/constant"
    17  	"github.com/laof/lite-speed-test/download"
    18  	"golang.org/x/image/font"
    19  )
    20  
    21  type Theme struct {
    22  	colorgroup [][]int
    23  	bounds     []int
    24  }
    25  
    26  var (
    27  	themes = map[string]Theme{
    28  		"original": Theme{
    29  			colorgroup: [][]int{
    30  				{255, 255, 255},
    31  				{128, 255, 0},
    32  				{255, 255, 0},
    33  				{255, 128, 192},
    34  				{255, 0, 0},
    35  			},
    36  			bounds: []int{0, 64 * 1024, 512 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024},
    37  		},
    38  		"rainbow": Theme{
    39  			colorgroup: [][]int{
    40  				{255, 255, 255},
    41  				{102, 255, 102},
    42  				{255, 255, 102},
    43  				{255, 178, 102},
    44  				{255, 102, 102},
    45  				{226, 140, 255},
    46  				{102, 204, 255},
    47  				{102, 102, 255},
    48  			},
    49  			bounds: []int{0, 64 * 1024, 512 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024, 24 * 1024 * 1024, 32 * 1024 * 1024, 40 * 1024 * 1024},
    50  		},
    51  	}
    52  
    53  	i18n = map[string]string{
    54  		"cn": `{
    55  			"Title":    "Lite SpeedTest 结果表",
    56  			"CreateAt": "测试时间",
    57  			"Traffic":  "总流量: %s. 总时间: %s, 可用节点: [%s]"
    58  		}`,
    59  		"en": `{
    60  			"Title":    "Lite SpeedTest Result Table",
    61  			"CreateAt": "Create At",
    62  			"Traffic":  "Traffic used: %s. Time used: %s, Working Nodes: [%s]"
    63  		}`,
    64  	}
    65  )
    66  
    67  type Node struct {
    68  	Id       int    `json:"id"`
    69  	Group    string `en:"Group" cn:"群组名" json:"group"`
    70  	Remarks  string `en:"Remarks" cn:"备注" json:"remarks"`
    71  	Protocol string `en:"Protocol" cn:"协议" json:"protocol"`
    72  	Ping     string `en:"Ping" cn:"Ping" json:"ping"`
    73  	AvgSpeed int64  `en:"AvgSpeed" cn:"平均速度" json:"avg_speed"`
    74  	MaxSpeed int64  `en:"MaxSpeed" cn:"最大速度" json:"max_speed"`
    75  	IsOk     bool   `json:"isok"`
    76  	Traffic  int64  `json:"traffic"`
    77  	Link     string `json:"link,omitempty"` // api only
    78  }
    79  
    80  func getNodeHeaders(language string) ([]string, map[string]string) {
    81  	kvs := map[string]string{}
    82  	var keys []string
    83  	t := reflect.TypeOf(Node{})
    84  	for i := 0; i < t.NumField(); i++ {
    85  		f := t.Field(i)
    86  		if v, ok := f.Tag.Lookup(language); ok {
    87  			kvs[f.Name] = v
    88  			keys = append(keys, f.Name)
    89  		}
    90  	}
    91  	return keys, kvs
    92  }
    93  
    94  type Nodes []Node
    95  
    96  func (nodes Nodes) Sort(sortMethod string) {
    97  	sort.Slice(nodes[:], func(i, j int) bool {
    98  		switch sortMethod {
    99  		case "speed":
   100  			return nodes[i].MaxSpeed < nodes[j].MaxSpeed
   101  		case "rspeed":
   102  			return nodes[i].MaxSpeed > nodes[j].MaxSpeed
   103  		case "ping":
   104  			return nodes[i].Ping < nodes[j].Ping
   105  		case "rping":
   106  			return nodes[i].Ping > nodes[j].Ping
   107  		default:
   108  			return true
   109  		}
   110  	})
   111  }
   112  
   113  func CSV2Nodes(path string) (Nodes, error) {
   114  	recordFile, err := os.Open(path)
   115  	if err != nil {
   116  		return nil, err
   117  	}
   118  	defer recordFile.Close()
   119  	reader := csv.NewReader(recordFile)
   120  	records, err := reader.ReadAll()
   121  	if err != nil {
   122  		return nil, err
   123  	}
   124  	nodes := make(Nodes, len(records))
   125  	for i, v := range records {
   126  		if len(v) < 6 {
   127  			continue
   128  		}
   129  		avg, err := strconv.Atoi(v[4])
   130  		if err != nil {
   131  			continue
   132  		}
   133  		max, err := strconv.Atoi(v[5])
   134  		if err != nil {
   135  			continue
   136  		}
   137  		nodes[i] = Node{
   138  			Group:    v[0],
   139  			Remarks:  v[1],
   140  			Protocol: v[2],
   141  			Ping:     v[3],
   142  			AvgSpeed: int64(avg),
   143  			MaxSpeed: int64(max),
   144  		}
   145  	}
   146  	return nodes, nil
   147  }
   148  
   149  type TableOptions struct {
   150  	horizontalpadding float64 // left + right
   151  	verticalpadding   float64 // up + down
   152  	tableTopPadding   float64 // padding for table
   153  	lineWidth         float64
   154  	fontHeight        float64
   155  	fontSize          int
   156  	smallFontRatio    float64
   157  	fontPath          string
   158  	language          string
   159  	theme             Theme
   160  	timezone          string
   161  	fontBytes         []byte
   162  }
   163  
   164  func NewTableOptions(horizontalpadding float64, verticalpadding float64, tableTopPadding float64,
   165  	lineWidth float64, fontSize int, smallFontRatio float64, fontPath string,
   166  	language string, t string, timezone string, fontBytes []byte) TableOptions {
   167  	theme, ok := themes[t]
   168  	if !ok {
   169  		theme = themes["rainbow"]
   170  	}
   171  	return TableOptions{
   172  		horizontalpadding: horizontalpadding,
   173  		verticalpadding:   verticalpadding,
   174  		tableTopPadding:   tableTopPadding,
   175  		lineWidth:         lineWidth,
   176  		fontSize:          fontSize,
   177  		smallFontRatio:    smallFontRatio,
   178  		fontPath:          fontPath,
   179  		language:          language,
   180  		theme:             theme,
   181  		timezone:          timezone,
   182  		fontBytes:         fontBytes,
   183  	}
   184  }
   185  
   186  type CellWidths struct {
   187  	Group    float64
   188  	Remarks  float64
   189  	Protocol float64
   190  	Ping     float64
   191  	AvgSpeed float64
   192  	MaxSpeed float64
   193  }
   194  
   195  func (c CellWidths) toMap() map[string]float64 {
   196  	data, _ := json.Marshal(&c)
   197  	m := map[string]float64{}
   198  	// ignore error
   199  	json.Unmarshal(data, &m)
   200  	return m
   201  }
   202  
   203  type I18N struct {
   204  	CreateAt string
   205  	Title    string
   206  	Traffic  string
   207  }
   208  
   209  func NewI18N(data string) (*I18N, error) {
   210  	i18n := &I18N{}
   211  	err := json.Unmarshal([]byte(data), i18n)
   212  	if err != nil {
   213  		return nil, err
   214  	}
   215  	return i18n, nil
   216  }
   217  
   218  type Table struct {
   219  	width  int
   220  	height int
   221  	*Context
   222  	nodes      Nodes
   223  	options    TableOptions
   224  	cellWidths *CellWidths
   225  	i18n       *I18N
   226  }
   227  
   228  func NewTable(width int, height int, options TableOptions) Table {
   229  	dc := NewContext(width, height)
   230  	return Table{
   231  		width:   width,
   232  		height:  height,
   233  		Context: dc,
   234  		options: options,
   235  	}
   236  }
   237  
   238  func DefaultTable(nodes Nodes, fontPath string) (*Table, error) {
   239  	options := NewTableOptions(40, 30, 0.5, 0.5, 24, 0.5, fontPath, "en", "rainbow", "Asia/Shanghai", nil)
   240  	return NewTableWithOption(nodes, &options)
   241  }
   242  
   243  // TODO: load font by name
   244  func NewTableWithOption(nodes Nodes, options *TableOptions) (*Table, error) {
   245  	fontSize := options.fontSize
   246  	fontPath := options.fontPath
   247  	fontface, err := LoadFontFaceByBytes(options.fontBytes, fontPath, float64(fontSize))
   248  	if err != nil {
   249  		return nil, err
   250  	}
   251  	widths := calcWidth(fontface, nodes)
   252  	fontHeight := calcHeight(fontface)
   253  	options.fontHeight = fontHeight
   254  	horizontalpadding := options.horizontalpadding
   255  	tableWidth := widths.Group + horizontalpadding + widths.Remarks + horizontalpadding + widths.Protocol + horizontalpadding + widths.Ping + horizontalpadding + widths.AvgSpeed + horizontalpadding + widths.MaxSpeed + horizontalpadding + options.lineWidth*2
   256  	tableHeight := (fontHeight+options.verticalpadding)*float64((len(nodes)+4)) + options.tableTopPadding*2 + options.fontHeight*options.smallFontRatio
   257  	table := NewTable(int(tableWidth), int(tableHeight), *options)
   258  	table.nodes = nodes
   259  	table.cellWidths = widths
   260  	result, err := NewI18N(i18n[options.language])
   261  	if err != nil {
   262  		return nil, err
   263  	}
   264  	table.i18n = result
   265  	table.SetFontFace(fontface)
   266  	return &table, nil
   267  }
   268  
   269  func (t *Table) drawHorizonLines() {
   270  	y := t.options.tableTopPadding
   271  	for i := 0; i <= len(t.nodes)+4; i++ {
   272  		t.drawHorizonLine(y)
   273  		y += t.options.fontHeight + t.options.verticalpadding
   274  	}
   275  }
   276  
   277  func (t *Table) drawHorizonLine(y float64) {
   278  	t.DrawLine(0, y, float64(t.width), y)
   279  	t.SetLineWidth(t.options.lineWidth)
   280  	t.Stroke()
   281  }
   282  
   283  func (t *Table) drawVerticalLines() {
   284  	padding := t.options.horizontalpadding
   285  	var x float64
   286  	t.drawFullVerticalLine(t.options.lineWidth)
   287  	ks, _ := getNodeHeaders(t.options.language)
   288  	cellWidths := t.cellWidths.toMap()
   289  	for i := 1; i < len(cellWidths); i++ {
   290  		k := ks[i-1]
   291  		x += cellWidths[k] + padding
   292  		t.drawVerticalLine(x)
   293  	}
   294  	x += cellWidths[ks[len(cellWidths)-1]] + padding
   295  	t.drawFullVerticalLine(x)
   296  }
   297  
   298  func (t *Table) drawVerticalLine(x float64) {
   299  	height := (t.options.fontHeight+t.options.verticalpadding)*float64((len(t.nodes)+2)) + t.options.tableTopPadding
   300  	y := t.options.tableTopPadding + t.options.fontHeight + t.options.verticalpadding
   301  	t.DrawLine(x, y, x, height)
   302  	t.SetLineWidth(t.options.lineWidth)
   303  	t.Stroke()
   304  }
   305  
   306  func (t *Table) drawFullVerticalLine(x float64) {
   307  	height := (t.options.fontHeight+t.options.verticalpadding)*float64((len(t.nodes)+4)) + t.options.tableTopPadding
   308  	y := t.options.tableTopPadding
   309  	t.DrawLine(x, y, x, height)
   310  	t.SetLineWidth(t.options.lineWidth)
   311  	t.Stroke()
   312  }
   313  
   314  func (t *Table) drawTitle() {
   315  	// horizontalpadding := t.options.horizontalpadding
   316  	title := t.i18n.Title
   317  	var x float64 = float64(t.width)/2 - getWidth(t.fontFace, title)/2
   318  	var y float64 = t.options.fontHeight/2 + t.options.verticalpadding/2 + t.options.tableTopPadding
   319  	t.centerString(title, x, y)
   320  }
   321  
   322  func (t *Table) drawHeader() {
   323  	horizontalpadding := t.options.horizontalpadding
   324  	var x float64 = horizontalpadding / 2
   325  	var y float64 = t.options.fontHeight/2 + t.options.verticalpadding/2 + t.options.tableTopPadding + t.options.fontHeight + t.options.verticalpadding
   326  	cellWidths := t.cellWidths.toMap()
   327  	ks, kvs := getNodeHeaders(t.options.language)
   328  	for _, k := range ks {
   329  		adjust := cellWidths[k]/2 - getWidth(t.fontFace, kvs[k])/2
   330  		t.centerString(kvs[k], x+adjust, y)
   331  		x += cellWidths[k] + horizontalpadding
   332  	}
   333  }
   334  
   335  func (t *Table) drawTraffic(traffic string) {
   336  	// horizontalpadding := t.options.horizontalpadding
   337  	var x float64 = t.options.horizontalpadding / 2
   338  	var y float64 = (t.options.fontHeight+t.options.verticalpadding)*float64((len(t.nodes)+2)) + t.options.tableTopPadding + t.fontHeight/2 + t.options.verticalpadding/2
   339  	t.centerString(traffic, x, y)
   340  }
   341  
   342  func (t *Table) FormatTraffic(traffic string, time string, workingNode string) string {
   343  	return fmt.Sprintf(t.i18n.Traffic, traffic, time, workingNode)
   344  }
   345  
   346  func (t *Table) drawGeneratedAt() {
   347  	// horizontalpadding := t.options.horizontalpadding
   348  	msg := fmt.Sprintf("%s %s", t.i18n.CreateAt, time.Now().Format(time.RFC3339))
   349  	// https://github.com/golang/go/issues/20455
   350  	if runtime.GOOS == "android" {
   351  		loc, _ := time.LoadLocation(t.options.timezone)
   352  		now := time.Now()
   353  		msg = fmt.Sprintf("%s %s", t.i18n.CreateAt, now.In(loc).Format(time.RFC3339))
   354  	}
   355  	var x float64 = t.options.horizontalpadding / 2
   356  	var y float64 = (t.options.fontHeight+t.options.verticalpadding)*float64((len(t.nodes)+3)) + t.options.tableTopPadding + t.fontHeight/2 + t.options.verticalpadding/2
   357  	t.centerString(msg, x, y)
   358  }
   359  
   360  func (t *Table) drawPoweredBy() {
   361  	fontSize := int(float64(t.options.fontSize) * t.options.smallFontRatio)
   362  	fontface, err := LoadFontFaceByBytes(t.options.fontBytes, t.options.fontPath, float64(fontSize))
   363  	if err != nil {
   364  		return
   365  	}
   366  	t.SetFontFace(fontface)
   367  	msg := constant.Version + " powered by https://github.com/xxf098"
   368  	var x float64 = float64(t.width) - getWidth(fontface, msg) - t.options.lineWidth
   369  	var y float64 = (t.options.fontHeight+t.options.verticalpadding)*float64((len(t.nodes)+4)) + t.options.fontHeight*t.options.smallFontRatio
   370  	t.DrawString(msg, x, y)
   371  }
   372  
   373  func (t *Table) centerString(s string, x, y float64) {
   374  	t.DrawStringAnchored(s, x, y, 0, 0.4)
   375  }
   376  
   377  func (t *Table) drawNodes() {
   378  	horizontalpadding := t.options.horizontalpadding
   379  	var x float64 = horizontalpadding / 2
   380  	var y float64 = t.options.fontHeight/2 + t.options.verticalpadding/2 + t.options.tableTopPadding + (t.options.fontHeight+t.options.verticalpadding)*2
   381  	for _, v := range t.nodes {
   382  		t.centerString(v.Group, x, y)
   383  		x += t.cellWidths.Group + horizontalpadding
   384  		t.centerString(v.Remarks, x, y)
   385  		x += t.cellWidths.Remarks + horizontalpadding
   386  		adjust := t.cellWidths.Protocol/2 - getWidth(t.fontFace, v.Protocol)/2
   387  		t.centerString(v.Protocol, x+adjust, y)
   388  		x += t.cellWidths.Protocol + horizontalpadding
   389  		adjust = t.cellWidths.Ping/2 - getWidth(t.fontFace, v.Ping)/2
   390  		t.centerString(v.Ping, x+adjust, y)
   391  		x += t.cellWidths.Ping + horizontalpadding
   392  		avgSpeed := download.ByteCountIECTrim(v.AvgSpeed)
   393  		adjust = t.cellWidths.AvgSpeed/2 - getWidth(t.fontFace, avgSpeed)/2
   394  		t.centerString(avgSpeed, x+adjust, y)
   395  		x += t.cellWidths.AvgSpeed + horizontalpadding
   396  		maxSpeed := download.ByteCountIECTrim(v.MaxSpeed)
   397  		adjust = t.cellWidths.MaxSpeed/2 - getWidth(t.fontFace, maxSpeed)/2
   398  		t.centerString(maxSpeed, x+adjust, y)
   399  		y += t.options.fontHeight + t.options.verticalpadding
   400  		x = horizontalpadding / 2
   401  	}
   402  }
   403  
   404  func (t *Table) drawSpeed() {
   405  	padding := t.options.horizontalpadding
   406  	var lineWidth float64 = t.options.lineWidth
   407  	var x1 float64 = t.cellWidths.Group + padding + t.cellWidths.Remarks + padding + t.cellWidths.Protocol + padding + t.cellWidths.Ping + padding + lineWidth
   408  	var x2 float64 = t.cellWidths.Group + padding + t.cellWidths.Remarks + padding + t.cellWidths.Protocol + padding + t.cellWidths.Ping + padding + t.cellWidths.AvgSpeed + padding + lineWidth
   409  	var y float64 = t.options.tableTopPadding + lineWidth + (t.options.fontHeight+t.options.verticalpadding)*2
   410  	var wAvg float64 = t.cellWidths.AvgSpeed + padding - lineWidth*2
   411  	var wMax float64 = t.cellWidths.MaxSpeed + padding - lineWidth*2
   412  	var h float64 = t.options.fontHeight + t.options.verticalpadding - 2*lineWidth
   413  	for i := 0; i < len(t.nodes); i++ {
   414  		t.DrawRectangle(x1, y, wAvg, h)
   415  		r, g, b := getSpeedColor(t.nodes[i].AvgSpeed, t.options.theme)
   416  		t.SetRGB255(r, g, b)
   417  		t.Fill()
   418  		t.DrawRectangle(x2, y, wMax, h)
   419  		r, g, b = getSpeedColor(t.nodes[i].MaxSpeed, t.options.theme)
   420  		t.SetRGB255(r, g, b)
   421  		t.Fill()
   422  		y = y + t.options.fontHeight + t.options.verticalpadding
   423  	}
   424  	t.SetRGB255(0, 0, 0)
   425  }
   426  
   427  func (t *Table) Draw(path string, traffic string) {
   428  	t.SetRGB255(255, 255, 255)
   429  	t.Clear()
   430  	t.SetRGB255(0, 0, 0)
   431  	t.drawHorizonLines()
   432  	t.drawVerticalLines()
   433  	t.drawSpeed()
   434  	t.drawTitle()
   435  	t.drawHeader()
   436  	t.drawNodes()
   437  	t.drawTraffic(traffic)
   438  	t.drawGeneratedAt()
   439  	t.drawPoweredBy()
   440  	t.SavePNG(path)
   441  }
   442  
   443  func (t *Table) Encode(traffic string) ([]byte, error) {
   444  	t.SetRGB255(255, 255, 255)
   445  	t.Clear()
   446  	t.SetRGB255(0, 0, 0)
   447  	t.drawHorizonLines()
   448  	t.drawVerticalLines()
   449  	t.drawSpeed()
   450  	t.drawTitle()
   451  	t.drawHeader()
   452  	t.drawNodes()
   453  	t.drawTraffic(traffic)
   454  	t.drawGeneratedAt()
   455  	t.drawPoweredBy()
   456  	var buf bytes.Buffer
   457  	err := t.EncodePNG(&buf)
   458  	if err != nil {
   459  		return nil, err
   460  	}
   461  	return buf.Bytes(), nil
   462  }
   463  
   464  func (t *Table) EncodeB64(traffic string) (string, error) {
   465  	bytes, err := t.Encode(traffic)
   466  	if err != nil {
   467  		return "", err
   468  	}
   469  	return "data:image/png;base64," + base64.StdEncoding.EncodeToString(bytes), nil
   470  }
   471  
   472  func getSpeedColor(speed int64, theme Theme) (int, int, int) {
   473  	bounds := theme.bounds
   474  	colorgroup := theme.colorgroup
   475  	for i := 0; i < len(bounds)-1; i++ {
   476  		if speed >= int64(bounds[i]) && speed <= int64(bounds[i+1]) {
   477  			level := float64(speed-int64(bounds[i])) / float64(bounds[i+1]-bounds[i])
   478  			return getColor(colorgroup[i], colorgroup[i+1], level)
   479  		}
   480  	}
   481  	l := len(colorgroup)
   482  	return colorgroup[l-1][0], colorgroup[l-1][1], colorgroup[l-1][2]
   483  }
   484  
   485  func getColor(lc []int, rc []int, level float64) (int, int, int) {
   486  	r := float64(lc[0])*(1-level) + float64(rc[0])*level
   487  	g := float64(lc[1])*(1-level) + float64(rc[1])*level
   488  	b := float64(lc[2])*(1-level) + float64(rc[2])*level
   489  	return int(r), int(g), int(b)
   490  }
   491  
   492  func calcWidth(fontface font.Face, nodes Nodes) *CellWidths {
   493  	cellWidths := &CellWidths{}
   494  	if len(nodes) < 1 {
   495  		return cellWidths
   496  	}
   497  	cellWidths.Group = getWidth(fontface, nodes[0].Group)
   498  	cellWidths.Protocol = getWidth(fontface, "Protocol")
   499  
   500  	for _, v := range nodes {
   501  		width := getWidth(fontface, v.Ping)
   502  		if cellWidths.Ping < width {
   503  			cellWidths.Ping = width
   504  		}
   505  		width = getWidth(fontface, download.ByteCountIECTrim(v.AvgSpeed))
   506  		if cellWidths.AvgSpeed < width {
   507  			cellWidths.AvgSpeed = width
   508  		}
   509  		width = getWidth(fontface, download.ByteCountIECTrim(v.MaxSpeed))
   510  		if cellWidths.MaxSpeed < width {
   511  			cellWidths.MaxSpeed = width
   512  		}
   513  		width = getWidth(fontface, v.Remarks)
   514  		if cellWidths.Remarks < width {
   515  			cellWidths.Remarks = width
   516  		}
   517  	}
   518  	if cellWidths.Group < getWidth(fontface, "Group") {
   519  		cellWidths.Group = getWidth(fontface, "Group")
   520  	}
   521  	if cellWidths.Remarks < getWidth(fontface, "Remarks") {
   522  		cellWidths.Remarks = getWidth(fontface, "Remarks")
   523  	}
   524  	if cellWidths.Ping < getWidth(fontface, "Ping") {
   525  		cellWidths.Ping = getWidth(fontface, "Ping")
   526  	}
   527  	if cellWidths.AvgSpeed < getWidth(fontface, "AvgSpeed") {
   528  		cellWidths.AvgSpeed = getWidth(fontface, "AvgSpeed")
   529  	}
   530  	if cellWidths.MaxSpeed < getWidth(fontface, "MaxSpeed") {
   531  		cellWidths.MaxSpeed = getWidth(fontface, "MaxSpeed")
   532  	}
   533  
   534  	return cellWidths
   535  }
   536  
   537  func calcHeight(fontface font.Face) float64 {
   538  	return float64(fontface.Metrics().Height) / 64
   539  }
   540  
   541  func getWidth(fontface font.Face, s string) float64 {
   542  	a := font.MeasureString(fontface, s)
   543  	return float64(a >> 6)
   544  }