241 lines
7.6 KiB
Go
241 lines
7.6 KiB
Go
|
|
package svg
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/xml"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"math"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"b612.me/astro/internal/svgchart"
|
||
|
|
)
|
||
|
|
|
||
|
|
// 详细版式里任意两段文字都不许压叠:包围盒口径与 internal/svgchart 一致,
|
||
|
|
// 出图尺寸要么 0 压叠,要么明确拒绝,不得返回带压叠文字的图。
|
||
|
|
type overlapTextBox struct {
|
||
|
|
text string
|
||
|
|
x, y, width, height float64
|
||
|
|
fontSize float64
|
||
|
|
}
|
||
|
|
|
||
|
|
func svgTextFloat(attrs map[string]string, name string) float64 {
|
||
|
|
value := strings.TrimRight(strings.TrimRight(attrs[name], "pt"), "px")
|
||
|
|
parsed, err := strconv.ParseFloat(value, 64)
|
||
|
|
if err != nil {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return parsed
|
||
|
|
}
|
||
|
|
|
||
|
|
func svgTextAnchorBox(box overlapTextBox, anchor string) overlapTextBox {
|
||
|
|
switch anchor {
|
||
|
|
case "middle":
|
||
|
|
box.x -= box.width / 2
|
||
|
|
case "end":
|
||
|
|
box.x -= box.width
|
||
|
|
}
|
||
|
|
return box
|
||
|
|
}
|
||
|
|
|
||
|
|
func canvasTextBoxes(t *testing.T, document string) []overlapTextBox {
|
||
|
|
t.Helper()
|
||
|
|
decoder := xml.NewDecoder(strings.NewReader(document))
|
||
|
|
boxes := make([]overlapTextBox, 0, 128)
|
||
|
|
var anchor string
|
||
|
|
var pending *overlapTextBox
|
||
|
|
var content strings.Builder
|
||
|
|
for {
|
||
|
|
token, err := decoder.Token()
|
||
|
|
if err == io.EOF {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("chart is not valid XML: %v", err)
|
||
|
|
}
|
||
|
|
switch element := token.(type) {
|
||
|
|
case xml.StartElement:
|
||
|
|
if element.Name.Local != "text" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
attrs := make(map[string]string, len(element.Attr))
|
||
|
|
for _, attribute := range element.Attr {
|
||
|
|
attrs[attribute.Name.Local] = attribute.Value
|
||
|
|
}
|
||
|
|
fontSize := svgTextFloat(attrs, "font-size")
|
||
|
|
above, below := svgchart.EstimatedTextExtents(fontSize)
|
||
|
|
pending = &overlapTextBox{
|
||
|
|
x: svgTextFloat(attrs, "x"), y: svgTextFloat(attrs, "y"),
|
||
|
|
height: above + below, fontSize: fontSize,
|
||
|
|
}
|
||
|
|
pending.y -= above
|
||
|
|
anchor = attrs["text-anchor"]
|
||
|
|
content.Reset()
|
||
|
|
case xml.CharData:
|
||
|
|
if pending != nil {
|
||
|
|
content.Write([]byte(element))
|
||
|
|
}
|
||
|
|
case xml.EndElement:
|
||
|
|
if element.Name.Local != "text" || pending == nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
pending.text = content.String()
|
||
|
|
pending.width = svgchart.EstimatedTextWidth(pending.text, pending.fontSize)
|
||
|
|
boxes = append(boxes, svgTextAnchorBox(*pending, anchor))
|
||
|
|
pending = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(boxes) == 0 {
|
||
|
|
t.Fatal("chart carries no text")
|
||
|
|
}
|
||
|
|
return boxes
|
||
|
|
}
|
||
|
|
|
||
|
|
func overlapArea(first, second overlapTextBox) float64 {
|
||
|
|
left, right := math.Max(first.x, second.x), math.Min(first.x+first.width, second.x+second.width)
|
||
|
|
top, bottom := math.Max(first.y, second.y), math.Min(first.y+first.height, second.y+second.height)
|
||
|
|
if right <= left || bottom <= top {
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
return (right - left) * (bottom - top)
|
||
|
|
}
|
||
|
|
|
||
|
|
func assertNoTextOverlap(t *testing.T, label, document string) {
|
||
|
|
t.Helper()
|
||
|
|
boxes := canvasTextBoxes(t, document)
|
||
|
|
for first := 0; first < len(boxes); first++ {
|
||
|
|
for second := first + 1; second < len(boxes); second++ {
|
||
|
|
area := overlapArea(boxes[first], boxes[second])
|
||
|
|
if area <= 0.5 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
t.Errorf("%s: %q [%.1f,%.1f w=%.1f h=%.1f] overlaps %q [%.1f,%.1f w=%.1f h=%.1f] by %.1f px²",
|
||
|
|
label,
|
||
|
|
boxes[first].text, boxes[first].x, boxes[first].y, boxes[first].width, boxes[first].height,
|
||
|
|
boxes[second].text, boxes[second].x, boxes[second].y, boxes[second].width, boxes[second].height,
|
||
|
|
area)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var eclipseOverlapSizes = [][2]int{
|
||
|
|
{800, 560}, {800, 600}, {960, 640}, {1000, 1414}, {1414, 1000}, {1200, 800},
|
||
|
|
}
|
||
|
|
|
||
|
|
type eclipseOverlapCase struct {
|
||
|
|
name string
|
||
|
|
date time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
var solarOverlapCases = []eclipseOverlapCase{
|
||
|
|
{name: "2024-04-08-total", date: time.Date(2024, time.April, 8, 18, 0, 0, 0, time.UTC)},
|
||
|
|
{name: "2010-01-15-annular", date: time.Date(2010, time.January, 15, 7, 0, 0, 0, time.UTC)},
|
||
|
|
{name: "2014-04-29-noncentral", date: time.Date(2014, time.April, 29, 6, 0, 0, 0, time.UTC)},
|
||
|
|
{name: "2009-07-22-total", date: time.Date(2009, time.July, 22, 2, 0, 0, 0, time.UTC)},
|
||
|
|
}
|
||
|
|
|
||
|
|
var lunarOverlapCases = []eclipseOverlapCase{
|
||
|
|
{name: "2025-03-14-total", date: time.Date(2025, time.March, 14, 6, 0, 0, 0, time.UTC)},
|
||
|
|
{name: "2016-08-18-penumbral", date: time.Date(2016, time.August, 18, 12, 0, 0, 0, time.UTC)},
|
||
|
|
}
|
||
|
|
|
||
|
|
func renderSolarOverlapCase(event eclipseOverlapCase, size [2]int, options SolarEclipseMapSVGOptions) (string, bool) {
|
||
|
|
options.Width, options.Height = size[0], size[1]
|
||
|
|
return SolarEclipseMapSVG(event.date, options)
|
||
|
|
}
|
||
|
|
|
||
|
|
func renderLunarOverlapCase(event eclipseOverlapCase, size [2]int, options LunarEclipseDetailedSVGOptions) (string, bool) {
|
||
|
|
options.Width, options.Height = size[0], size[1]
|
||
|
|
return LunarEclipseDetailedSVG(event.date, options)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 事件 × 尺寸矩阵:返回 ok=true 的产物必须 0 压叠,被拒绝的必须返回空串。
|
||
|
|
func TestSolarEclipseMapTextOverlapMatrix(t *testing.T) {
|
||
|
|
accepted := make(map[string]bool)
|
||
|
|
for _, event := range solarOverlapCases {
|
||
|
|
for _, size := range eclipseOverlapSizes {
|
||
|
|
name := fmt.Sprintf("%s/%dx%d", event.name, size[0], size[1])
|
||
|
|
document, ok := renderSolarOverlapCase(event, size, SolarEclipseMapSVGOptions{})
|
||
|
|
accepted[name] = ok
|
||
|
|
if !ok {
|
||
|
|
if document != "" {
|
||
|
|
t.Errorf("%s: rejected the canvas but returned %d bytes", name, len(document))
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
assertNoTextOverlap(t, name, document)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// 文档下限与默认尺寸必须继续出图:拒绝只允许出现在真的放不下的画布上。
|
||
|
|
for _, name := range []string{
|
||
|
|
"2024-04-08-total/800x560", "2024-04-08-total/800x600", "2024-04-08-total/960x640",
|
||
|
|
"2010-01-15-annular/800x560", "2014-04-29-noncentral/800x560", "2009-07-22-total/1200x800",
|
||
|
|
} {
|
||
|
|
if !accepted[name] {
|
||
|
|
t.Errorf("%s: documented size was rejected", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLunarEclipseDetailedTextOverlapMatrix(t *testing.T) {
|
||
|
|
accepted := make(map[string]bool)
|
||
|
|
for _, event := range lunarOverlapCases {
|
||
|
|
for _, size := range eclipseOverlapSizes {
|
||
|
|
name := fmt.Sprintf("%s/%dx%d", event.name, size[0], size[1])
|
||
|
|
document, ok := renderLunarOverlapCase(event, size, LunarEclipseDetailedSVGOptions{})
|
||
|
|
accepted[name] = ok
|
||
|
|
if !ok {
|
||
|
|
if document != "" {
|
||
|
|
t.Errorf("%s: rejected the canvas but returned %d bytes", name, len(document))
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
assertNoTextOverlap(t, name, document)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, name := range []string{
|
||
|
|
"2025-03-14-total/1000x1414", "2025-03-14-total/1414x1000",
|
||
|
|
"2016-08-18-penumbral/1000x1414", "2016-08-18-penumbral/1414x1000",
|
||
|
|
} {
|
||
|
|
if !accepted[name] {
|
||
|
|
t.Errorf("%s: documented size was rejected", name)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 英文文案更长、可选图层会撑出更多图例行,两种扩展都必须保持 0 压叠。
|
||
|
|
func TestEclipseChartTextOverlapWithLongLabels(t *testing.T) {
|
||
|
|
for _, size := range [][2]int{{800, 560}, {960, 640}, {1000, 1414}} {
|
||
|
|
name := fmt.Sprintf("solar-en/%dx%d", size[0], size[1])
|
||
|
|
document, ok := renderSolarOverlapCase(solarOverlapCases[0], size, SolarEclipseMapSVGOptions{Language: "en"})
|
||
|
|
if !ok {
|
||
|
|
t.Errorf("%s: documented size was rejected", name)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
assertNoTextOverlap(t, name, document)
|
||
|
|
}
|
||
|
|
for _, size := range [][2]int{{800, 560}, {960, 640}} {
|
||
|
|
name := fmt.Sprintf("solar-all-layers/%dx%d", size[0], size[1])
|
||
|
|
document, ok := renderSolarOverlapCase(solarOverlapCases[0], size, SolarEclipseMapSVGOptions{
|
||
|
|
GreatestTimeStep: 30 * time.Minute, PenumbralOutlineStep: time.Hour, CentralShadowStep: 10 * time.Minute,
|
||
|
|
})
|
||
|
|
if !ok {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
assertNoTextOverlap(t, name, document)
|
||
|
|
}
|
||
|
|
for _, event := range lunarOverlapCases {
|
||
|
|
for _, size := range [][2]int{{1000, 1414}, {1414, 1000}} {
|
||
|
|
name := fmt.Sprintf("lunar-en/%s/%dx%d", event.name, size[0], size[1])
|
||
|
|
document, ok := renderLunarOverlapCase(event, size, LunarEclipseDetailedSVGOptions{Language: "en"})
|
||
|
|
if !ok {
|
||
|
|
t.Errorf("%s: documented size was rejected", name)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
assertNoTextOverlap(t, name, document)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|