233 lines
7.8 KiB
Go
233 lines
7.8 KiB
Go
|
|
package svg
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"encoding/xml"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"math"
|
|||
|
|
"sort"
|
|||
|
|
"strconv"
|
|||
|
|
"strings"
|
|||
|
|
"testing"
|
|||
|
|
"time"
|
|||
|
|
|
|||
|
|
"b612.me/astro/internal/svgchart"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// 页脚排版契约:任何文字的下沿都要离画布底边留出余量,站心图的页脚各行必须等行距。
|
|||
|
|
// 这两条都是排版不变量,与具体事件无关,因此在这里按图种各取一个样本固定下来。
|
|||
|
|
|
|||
|
|
const chartBottomMarginMinimum = 6.0
|
|||
|
|
|
|||
|
|
type chartTextElement struct {
|
|||
|
|
x float64
|
|||
|
|
y float64
|
|||
|
|
fontSize float64
|
|||
|
|
fill string
|
|||
|
|
value string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func chartTextElements(t *testing.T, document string) []chartTextElement {
|
|||
|
|
t.Helper()
|
|||
|
|
decoder := xml.NewDecoder(strings.NewReader(document))
|
|||
|
|
elements := []chartTextElement{}
|
|||
|
|
stack := []string{}
|
|||
|
|
for {
|
|||
|
|
token, err := decoder.Token()
|
|||
|
|
if err == io.EOF {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
if err != nil {
|
|||
|
|
t.Fatalf("chart is not valid XML: %v", err)
|
|||
|
|
}
|
|||
|
|
switch typed := token.(type) {
|
|||
|
|
case xml.StartElement:
|
|||
|
|
stack = append(stack, typed.Name.Local)
|
|||
|
|
if typed.Name.Local != "text" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
element := chartTextElement{}
|
|||
|
|
for _, attribute := range typed.Attr {
|
|||
|
|
switch attribute.Name.Local {
|
|||
|
|
case "x":
|
|||
|
|
element.x, _ = strconv.ParseFloat(attribute.Value, 64)
|
|||
|
|
case "y":
|
|||
|
|
element.y, _ = strconv.ParseFloat(attribute.Value, 64)
|
|||
|
|
case "font-size":
|
|||
|
|
element.fontSize, _ = strconv.ParseFloat(attribute.Value, 64)
|
|||
|
|
case "fill":
|
|||
|
|
element.fill = attribute.Value
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
elements = append(elements, element)
|
|||
|
|
case xml.EndElement:
|
|||
|
|
stack = stack[:len(stack)-1]
|
|||
|
|
case xml.CharData:
|
|||
|
|
if len(stack) > 0 && stack[len(stack)-1] == "text" && len(elements) > 0 {
|
|||
|
|
elements[len(elements)-1].value += string(typed)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return elements
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func chartBottomMarginViolations(t *testing.T, document string, height int) []string {
|
|||
|
|
t.Helper()
|
|||
|
|
violations := []string{}
|
|||
|
|
for _, element := range chartTextElements(t, document) {
|
|||
|
|
if strings.TrimSpace(element.value) == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
_, below := svgchart.EstimatedTextExtents(element.fontSize)
|
|||
|
|
if margin := float64(height) - (element.y + below); margin < chartBottomMarginMinimum {
|
|||
|
|
violations = append(violations, fmt.Sprintf("%q 距底边 %.2f px(下限 %.1f)", element.value, margin, chartBottomMarginMinimum))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return violations
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func chartFooterCases(t *testing.T) []struct {
|
|||
|
|
name string
|
|||
|
|
height int
|
|||
|
|
document string
|
|||
|
|
} {
|
|||
|
|
t.Helper()
|
|||
|
|
cst := time.FixedZone("CST", 8*3600)
|
|||
|
|
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
|
|||
|
|
long := footerFitLongText("footer ")
|
|||
|
|
cases := []struct {
|
|||
|
|
name string
|
|||
|
|
height int
|
|||
|
|
document string
|
|||
|
|
}{}
|
|||
|
|
add := func(name string, height int, document string, ok bool) {
|
|||
|
|
if !ok {
|
|||
|
|
t.Fatalf("%s: chart missing", name)
|
|||
|
|
}
|
|||
|
|
cases = append(cases, struct {
|
|||
|
|
name string
|
|||
|
|
height int
|
|||
|
|
document string
|
|||
|
|
}{name, height, document})
|
|||
|
|
}
|
|||
|
|
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
|
|||
|
|
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
|
|||
|
|
add("站心日食", 692, solar, ok)
|
|||
|
|
solarLong, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
|
|||
|
|
LocalSolarEclipseSVGOptions{Width: 640, Height: 520, Location: cst, FooterNote: long})
|
|||
|
|
add("站心日食 窄画布长说明", 520, solarLong, ok)
|
|||
|
|
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
|
|||
|
|
add("站心月食", 640, lunar, ok)
|
|||
|
|
lunarLong, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 640, Height: 520, Language: "zh", Location: cst, FooterNote: long})
|
|||
|
|
add("站心月食 窄画布长说明", 520, lunarLong, ok)
|
|||
|
|
detailed, ok := LunarEclipseDetailedSVG(lunarDate, LunarEclipseDetailedSVGOptions{Width: 1000, Height: 1000, Language: "zh", Location: cst, FooterNote: long})
|
|||
|
|
add("月食详图 长说明", 1000, detailed, ok)
|
|||
|
|
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
|
|||
|
|
add("月食地图 长说明", 640, mapChart, ok)
|
|||
|
|
solarMap, ok := SolarEclipseMapSVG(time.Date(2024, 4, 8, 0, 0, 0, 0, time.UTC),
|
|||
|
|
SolarEclipseMapSVGOptions{Width: 960, Height: 640, Location: time.UTC, FooterNote: long})
|
|||
|
|
add("日食地图 长说明", 640, solarMap, ok)
|
|||
|
|
return cases
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestChartTextKeepsBottomMargin(t *testing.T) {
|
|||
|
|
for _, tc := range chartFooterCases(t) {
|
|||
|
|
if violations := chartBottomMarginViolations(t, tc.document, tc.height); len(violations) > 0 {
|
|||
|
|
t.Errorf("%s:%d 行文字贴到画布底边,首条 %s", tc.name, len(violations), violations[0])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func TestLocalDiagramFooterLinesAreEvenlySpaced(t *testing.T) {
|
|||
|
|
cst := time.FixedZone("CST", 8*3600)
|
|||
|
|
cases := []struct {
|
|||
|
|
name string
|
|||
|
|
height int
|
|||
|
|
document string
|
|||
|
|
}{}
|
|||
|
|
solar, ok := LocalSolarEclipseSVG(time.Date(2009, 7, 22, 12, 0, 0, 0, cst), 121.9850, 30.6167, 0,
|
|||
|
|
LocalSolarEclipseSVGOptions{Width: 923, Height: 692, Location: cst})
|
|||
|
|
if !ok {
|
|||
|
|
t.Fatal("站心日食:缺图")
|
|||
|
|
}
|
|||
|
|
cases = append(cases, struct {
|
|||
|
|
name string
|
|||
|
|
height int
|
|||
|
|
document string
|
|||
|
|
}{"站心日食", 692, solar})
|
|||
|
|
lunar, ok := LunarEclipseSVG(time.Date(2026, 3, 3, 0, 0, 0, 0, cst), LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst})
|
|||
|
|
if !ok {
|
|||
|
|
t.Fatal("站心月食:缺图")
|
|||
|
|
}
|
|||
|
|
cases = append(cases, struct {
|
|||
|
|
name string
|
|||
|
|
height int
|
|||
|
|
document string
|
|||
|
|
}{"站心月食", 640, lunar})
|
|||
|
|
|
|||
|
|
for _, tc := range cases {
|
|||
|
|
baselines := []float64{}
|
|||
|
|
for _, element := range chartTextElements(t, tc.document) {
|
|||
|
|
if element.fontSize != 12 || (element.fill != "#333" && element.fill != "#555") {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
if strings.TrimSpace(element.value) == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
baselines = append(baselines, element.y)
|
|||
|
|
}
|
|||
|
|
if len(baselines) < 3 {
|
|||
|
|
t.Fatalf("%s:页脚只有 %d 行,样本太少", tc.name, len(baselines))
|
|||
|
|
}
|
|||
|
|
sort.Float64s(baselines)
|
|||
|
|
step := svgchart.FooterLineHeight(12)
|
|||
|
|
for index := 1; index < len(baselines); index++ {
|
|||
|
|
if gap := baselines[index] - baselines[index-1]; math.Abs(gap-step) > 0.01 {
|
|||
|
|
t.Fatalf("%s:页脚第 %d 行与上一行间距 %.3f,应为 %.1f(基线 %v)", tc.name, index+1, gap, step, baselines)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if margin := float64(tc.height) - baselines[len(baselines)-1]; math.Abs(margin-localDiagramFooterBottomPadding) > 0.01 {
|
|||
|
|
t.Fatalf("%s:页脚末行基线距底边 %.3f,应为 %.1f(须落在图框内)", tc.name, margin, localDiagramFooterBottomPadding)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 页脚时标声明契约:调用方给超长自定义说明时,时标声明仍须留在页脚里(截断可以吃掉说明,不能吃掉声明)。
|
|||
|
|
func TestChartFooterKeepsTimeScaleDeclaration(t *testing.T) {
|
|||
|
|
long := footerFitLongText("scale ")
|
|||
|
|
cst := time.FixedZone("CST", 8*3600)
|
|||
|
|
lunarDate := time.Date(2026, 3, 3, 0, 0, 0, 0, cst)
|
|||
|
|
cases := []struct {
|
|||
|
|
name string
|
|||
|
|
document string
|
|||
|
|
ok bool
|
|||
|
|
}{}
|
|||
|
|
solar, ok := LocalSolarEclipseSVG(time.Date(2012, 5, 21, 12, 0, 0, 0, cst), 118.0894, 24.4798, 0,
|
|||
|
|
LocalSolarEclipseSVGOptions{Width: 920, Height: 720, Location: cst, FooterNote: long})
|
|||
|
|
cases = append(cases, struct {
|
|||
|
|
name string
|
|||
|
|
document string
|
|||
|
|
ok bool
|
|||
|
|
}{"站心日食", solar, ok})
|
|||
|
|
lunar, ok := LunarEclipseSVG(lunarDate, LunarEclipseSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
|
|||
|
|
cases = append(cases, struct {
|
|||
|
|
name string
|
|||
|
|
document string
|
|||
|
|
ok bool
|
|||
|
|
}{"站心月食", lunar, ok})
|
|||
|
|
mapChart, ok := LunarEclipseMapSVG(lunarDate, LunarEclipseMapSVGOptions{Width: 960, Height: 640, Language: "zh", Location: cst, FooterNote: long})
|
|||
|
|
cases = append(cases, struct {
|
|||
|
|
name string
|
|||
|
|
document string
|
|||
|
|
ok bool
|
|||
|
|
}{"月食地图", mapChart, ok})
|
|||
|
|
for _, tc := range cases {
|
|||
|
|
if !tc.ok {
|
|||
|
|
t.Fatalf("%s:缺图", tc.name)
|
|||
|
|
}
|
|||
|
|
if !strings.Contains(tc.document, "显示时区") && !strings.Contains(tc.document, "图中时刻为 UTC") {
|
|||
|
|
t.Errorf("%s:超长说明把时标声明挤掉了", tc.name)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|