feat: 完善日月食与月掩几何链路并扩展历法接口
- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑 - 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口 - 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界 - 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名 - 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验 - 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口 - 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
package svgchart
|
||||
|
||||
// LabelBox 是文本或图形占位的保守包围盒。/ LabelBox is a conservative bounding box for a reserved label or graphic.
|
||||
type LabelBox struct {
|
||||
X, Y, Width, Height float64
|
||||
}
|
||||
|
||||
// LabelPlacement 是一个候选位次:文本锚点与 text-anchor 取值。
|
||||
// LabelPlacement is one candidate slot: the text anchor point and its text-anchor value.
|
||||
type LabelPlacement struct {
|
||||
X, Y float64
|
||||
Anchor string
|
||||
}
|
||||
|
||||
// LabelTable 记录已占位的矩形,后来的标签按候选顺序避让。
|
||||
// LabelTable records reserved rectangles; later labels avoid them in candidate order.
|
||||
type LabelTable struct {
|
||||
boxes []LabelBox
|
||||
}
|
||||
|
||||
// labelClearance 是落位判定要求的间隙:包围盒各向外放 1 px,写出的坐标取整后两个盒子也不会贴成相交。
|
||||
const labelClearance = 1.0
|
||||
|
||||
// Reserve 占位一个矩形;非正宽高直接忽略。
|
||||
// Reserve claims a rectangle; non-positive extents are ignored.
|
||||
func (table *LabelTable) Reserve(x, y, width, height float64) {
|
||||
if width <= 0 || height <= 0 {
|
||||
return
|
||||
}
|
||||
table.boxes = append(table.boxes, LabelBox{X: x, Y: y, Width: width, Height: height})
|
||||
}
|
||||
|
||||
// LabelTextBox 返回文本的保守包围盒,anchor 取 start、middle 或 end。
|
||||
// LabelTextBox returns a text's conservative box for a start, middle, or end anchor.
|
||||
func LabelTextBox(x, baseline, fontSize float64, text, anchor string) LabelBox {
|
||||
if fontSize <= 0 {
|
||||
fontSize = 12
|
||||
}
|
||||
width := EstimatedTextWidth(text, fontSize)
|
||||
left := x
|
||||
switch anchor {
|
||||
case "middle":
|
||||
left = x - width/2
|
||||
case "end":
|
||||
left = x - width
|
||||
}
|
||||
above, below := EstimatedTextExtents(fontSize)
|
||||
return LabelBox{X: left, Y: baseline - above, Width: width, Height: above + below}
|
||||
}
|
||||
|
||||
// ReserveText 占位一段文本并返回它的包围盒。
|
||||
// ReserveText claims a text box and returns it.
|
||||
func (table *LabelTable) ReserveText(x, baseline, fontSize float64, text, anchor string) LabelBox {
|
||||
box := LabelTextBox(x, baseline, fontSize, text, anchor)
|
||||
table.Reserve(box.X, box.Y, box.Width, box.Height)
|
||||
return box
|
||||
}
|
||||
|
||||
// Overlaps 报告矩形是否压到已占位矩形;两个盒子之间至少留 labelClearance。
|
||||
// Overlaps reports whether a rectangle collides with any reserved box, keeping labelClearance between them.
|
||||
func (table *LabelTable) Overlaps(x, y, width, height float64) bool {
|
||||
for _, box := range table.boxes {
|
||||
if box.X+box.Width+labelClearance <= x || x+width+labelClearance <= box.X {
|
||||
continue
|
||||
}
|
||||
if box.Y+box.Height+labelClearance <= y || y+height+labelClearance <= box.Y {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// OverlapsText 报告文本是否压到已占位矩形。
|
||||
// OverlapsText reports whether a text collides with any reserved box.
|
||||
func (table *LabelTable) OverlapsText(x, baseline, fontSize float64, text, anchor string) bool {
|
||||
box := LabelTextBox(x, baseline, fontSize, text, anchor)
|
||||
return table.Overlaps(box.X, box.Y, box.Width, box.Height)
|
||||
}
|
||||
|
||||
// Place 按候选顺序放一段文本:返回首个不压叠的位次并占位;全部压叠时返回 false 且不占位。
|
||||
// Place takes the first candidate that keeps the text clear, claims it, and reports false when none does.
|
||||
func (table *LabelTable) Place(text string, fontSize float64, candidates []LabelPlacement) (LabelPlacement, bool) {
|
||||
for _, candidate := range candidates {
|
||||
box := LabelTextBox(candidate.X, candidate.Y, fontSize, text, candidate.Anchor)
|
||||
if table.Overlaps(box.X, box.Y, box.Width, box.Height) {
|
||||
continue
|
||||
}
|
||||
table.Reserve(box.X, box.Y, box.Width, box.Height)
|
||||
return candidate, true
|
||||
}
|
||||
return LabelPlacement{}, false
|
||||
}
|
||||
|
||||
// MirrorLabelAnchor 返回镜像后的 text-anchor:start 与 end 互换,middle 不变。
|
||||
// MirrorLabelAnchor flips start and end, and keeps middle.
|
||||
func MirrorLabelAnchor(anchor string) string {
|
||||
switch anchor {
|
||||
case "start":
|
||||
return "end"
|
||||
case "end":
|
||||
return "start"
|
||||
default:
|
||||
return "middle"
|
||||
}
|
||||
}
|
||||
|
||||
// LabelCandidates 生成绕锚点的候选位次:原位、上下左右、四角,再按镜像 anchor 重复一遍。
|
||||
// LabelCandidates lists candidate slots around an anchor: in place, then the four sides, then the
|
||||
// four corners, repeated for the mirrored anchor.
|
||||
func LabelCandidates(x, y float64, anchor string, radius float64) []LabelPlacement {
|
||||
candidates := make([]LabelPlacement, 0, 18)
|
||||
for _, current := range []string{anchor, MirrorLabelAnchor(anchor)} {
|
||||
candidates = append(candidates,
|
||||
LabelPlacement{X: x, Y: y, Anchor: current},
|
||||
LabelPlacement{X: x, Y: y - radius, Anchor: current},
|
||||
LabelPlacement{X: x, Y: y + radius, Anchor: current},
|
||||
LabelPlacement{X: x + radius, Y: y, Anchor: current},
|
||||
LabelPlacement{X: x - radius, Y: y, Anchor: current},
|
||||
LabelPlacement{X: x + radius, Y: y - radius, Anchor: current},
|
||||
LabelPlacement{X: x - radius, Y: y - radius, Anchor: current},
|
||||
LabelPlacement{X: x + radius, Y: y + radius, Anchor: current},
|
||||
LabelPlacement{X: x - radius, Y: y + radius, Anchor: current},
|
||||
)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package svgchart 提供日月食与月掩星详细版式共用的数据块与版面计算。
|
||||
// Package svgchart holds the data blocks and page geometry shared by the detailed eclipse and occultation charts.
|
||||
package svgchart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"math"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PanelRow 是数据块里的一行;Detail 非空时在数值下方补一行灰色小字。
|
||||
type PanelRow struct {
|
||||
Label string
|
||||
Value string
|
||||
Detail string
|
||||
}
|
||||
|
||||
// PanelBlock 是一个带标题的数据块。
|
||||
type PanelBlock struct {
|
||||
Title string
|
||||
Rows []PanelRow
|
||||
}
|
||||
|
||||
// WritePanelBox 画一个数据块:标题加若干行“标签 + 数值”。
|
||||
// 行高按行数等分,因此块高变化时不会溢出;Class 用于区分各族图的样式钩子。
|
||||
func WritePanelBox(
|
||||
b *strings.Builder,
|
||||
class string,
|
||||
x, y, width, height float64,
|
||||
title string,
|
||||
rows []PanelRow,
|
||||
) {
|
||||
if len(rows) == 0 || height <= 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(b, `<g class="%s">`, class)
|
||||
fmt.Fprintf(b, `<rect x="%.3f" y="%.3f" width="%.3f" height="%.3f" rx="3" fill="#fbfcfd" stroke="#dde3e9"/>`,
|
||||
x, y, width, height)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#111827" font-family="Georgia, 'Times New Roman', serif" font-size="12" font-weight="700">%s</text>`,
|
||||
x+12, y+20, html.EscapeString(title))
|
||||
rowHeight := (height - panelBoxHeaderHeight) / float64(len(rows))
|
||||
for index, row := range rows {
|
||||
if row.Label == "" {
|
||||
continue
|
||||
}
|
||||
rowY := y + panelBoxHeaderHeight + float64(index)*rowHeight
|
||||
size := PanelRowFontSize(width, row)
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#475569" font-family="Arial, sans-serif" font-size="%.1f">%s</text>`,
|
||||
x+panelBoxPadding, rowY+11, size, html.EscapeString(row.Label))
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#0f172a" font-family="Arial, sans-serif" font-size="%.1f" font-weight="700" text-anchor="end">%s</text>`,
|
||||
x+width-panelBoxPadding, rowY+11, size, html.EscapeString(row.Value))
|
||||
if row.Detail != "" {
|
||||
fmt.Fprintf(b, `<text x="%.3f" y="%.3f" fill="#7b8794" font-family="Arial, sans-serif" font-size="%d">%s</text>`,
|
||||
x+panelBoxPadding, rowY+22, panelRowDetailSize, html.EscapeString(row.Detail))
|
||||
}
|
||||
}
|
||||
b.WriteString(`</g>`)
|
||||
}
|
||||
|
||||
const (
|
||||
panelBoxHeaderHeight = 30
|
||||
panelRowLabelSize = 10.5
|
||||
panelRowDetailSize = 9
|
||||
panelBoxPadding = 12
|
||||
panelLabelValueGap = 8
|
||||
panelMinimumFontSize = 8
|
||||
)
|
||||
|
||||
// EstimatedTextWidth 按字号估算文本宽度:ASCII 取 0.62em,非 ASCII(含全角中文)取 1.05em。
|
||||
// 没有字体度量可用,这里只服务于压叠判定,估值偏大以保证判定保守。
|
||||
// EstimatedTextWidth estimates text width from the font size: 0.62em per ASCII rune and 1.05em
|
||||
// otherwise. It exists only for overlap checks, so it deliberately over-estimates.
|
||||
func EstimatedTextWidth(text string, fontSize float64) float64 {
|
||||
units := 0.0
|
||||
for _, symbol := range text {
|
||||
if symbol < 128 {
|
||||
units += 0.62
|
||||
continue
|
||||
}
|
||||
units += 1.05
|
||||
}
|
||||
return units * fontSize
|
||||
}
|
||||
|
||||
// EstimatedTextExtents 返回文本相对基线的上下留白,用于构造占位矩形。
|
||||
// EstimatedTextExtents returns the space a text keeps above and below its baseline.
|
||||
func EstimatedTextExtents(fontSize float64) (above, below float64) {
|
||||
return 0.85 * fontSize, 0.30 * fontSize
|
||||
}
|
||||
|
||||
// PanelRowFontSize 返回该行“标签 + 数值”放不下时等比缩小后的字号;缩小到下限仍放不下说明块太窄。
|
||||
// PanelRowFontSize shrinks a row's font until its label and value fit side by side.
|
||||
func PanelRowFontSize(width float64, row PanelRow) float64 {
|
||||
content := width - 2*panelBoxPadding
|
||||
if content <= 0 {
|
||||
return panelMinimumFontSize
|
||||
}
|
||||
pair := EstimatedTextWidth(row.Label, panelRowLabelSize) + EstimatedTextWidth(row.Value, panelRowLabelSize)
|
||||
if pair+panelLabelValueGap <= content {
|
||||
return panelRowLabelSize
|
||||
}
|
||||
size := panelRowLabelSize * (content - panelLabelValueGap) / pair
|
||||
if size < panelMinimumFontSize {
|
||||
return panelMinimumFontSize
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func panelTextRect(x, baseline, fontSize float64, text string, anchorEnd bool) panelTextBox {
|
||||
width := EstimatedTextWidth(text, fontSize)
|
||||
left := x
|
||||
if anchorEnd {
|
||||
left = x - width
|
||||
}
|
||||
above, below := EstimatedTextExtents(fontSize)
|
||||
return panelTextBox{x: left, y: baseline - above, width: width, height: above + below}
|
||||
}
|
||||
|
||||
// panelTextBox 是文本的保守包围盒。
|
||||
type panelTextBox struct {
|
||||
x, y, width, height float64
|
||||
}
|
||||
|
||||
func panelTextBoxesOverlap(first, second panelTextBox) bool {
|
||||
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)
|
||||
return right-left > 0.5 && bottom-top > 0
|
||||
}
|
||||
|
||||
// PanelBoxOverlaps 报告该块在给定位次与尺寸下,块内文本是否会互相压叠。
|
||||
// 行高按行数等分,所以块太矮时行距会小于文字高度,此时必须由调用方拒绝该画布而不是画出压叠的文字。
|
||||
// PanelBoxOverlaps reports whether the block's own texts would collide at the given box; callers must
|
||||
// reject a canvas whose panels cannot keep the rows apart.
|
||||
func PanelBoxOverlaps(x, y, width, height float64, title string, rows []PanelRow) bool {
|
||||
if len(rows) == 0 || height <= 0 {
|
||||
return false
|
||||
}
|
||||
boxes := make([]panelTextBox, 0, 2*len(rows)+1)
|
||||
boxes = append(boxes, panelTextRect(x+12, y+20, 12, title, false))
|
||||
rowHeight := (height - panelBoxHeaderHeight) / float64(len(rows))
|
||||
for index, row := range rows {
|
||||
if row.Label == "" {
|
||||
continue
|
||||
}
|
||||
rowY := y + panelBoxHeaderHeight + float64(index)*rowHeight
|
||||
size := PanelRowFontSize(width, row)
|
||||
boxes = append(boxes, panelTextRect(x+panelBoxPadding, rowY+11, size, row.Label, false))
|
||||
boxes = append(boxes, panelTextRect(x+width-panelBoxPadding, rowY+11, size, row.Value, true))
|
||||
if row.Detail != "" {
|
||||
boxes = append(boxes, panelTextRect(x+panelBoxPadding, rowY+22, panelRowDetailSize, row.Detail, false))
|
||||
}
|
||||
}
|
||||
for first := 0; first < len(boxes); first++ {
|
||||
for second := first + 1; second < len(boxes); second++ {
|
||||
if panelTextBoxesOverlap(boxes[first], boxes[second]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package svgchart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEstimatedTextWidthUsesSeparateAsciiAndWideRuneUnits(t *testing.T) {
|
||||
if got := EstimatedTextWidth("", 10); got != 0 {
|
||||
t.Fatalf("empty text width = %v, want 0", got)
|
||||
}
|
||||
if got := EstimatedTextWidth("AB", 10); math.Abs(got-12.4) > 1e-9 {
|
||||
t.Fatalf("two ASCII runes = %v, want 12.4", got)
|
||||
}
|
||||
if got := EstimatedTextWidth("中文", 10); math.Abs(got-21) > 1e-9 {
|
||||
t.Fatalf("two wide runes = %v, want 21", got)
|
||||
}
|
||||
above, below := EstimatedTextExtents(10)
|
||||
if math.Abs(above-8.5) > 1e-9 || math.Abs(below-3) > 1e-9 {
|
||||
t.Fatalf("extents = %v/%v, want 8.5/3", above, below)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelRowFontSizeShrinksUntilLabelAndValueFit(t *testing.T) {
|
||||
row := PanelRow{Label: "地平视差 H.P.", Value: "00°16'21.4\""}
|
||||
if got := PanelRowFontSize(320, row); got != panelRowLabelSize {
|
||||
t.Fatalf("wide box font = %v, want the base size", got)
|
||||
}
|
||||
narrow := 170.0
|
||||
size := PanelRowFontSize(narrow, row)
|
||||
if size >= panelRowLabelSize || size < panelMinimumFontSize {
|
||||
t.Fatalf("narrow box font = %v, want a size in [%v,%v)", size, panelMinimumFontSize, panelRowLabelSize)
|
||||
}
|
||||
content := narrow - 2*panelBoxPadding
|
||||
pair := EstimatedTextWidth(row.Label, size) + EstimatedTextWidth(row.Value, size)
|
||||
if pair+panelLabelValueGap > content {
|
||||
t.Fatalf("shrunk label %v + value %v + gap does not fit %v", pair, panelLabelValueGap, content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelBoxOverlapsDetectsCrowdedRows(t *testing.T) {
|
||||
rows := []PanelRow{
|
||||
{Label: "赤经 R.A.", Value: "23h21m52.2s"},
|
||||
{Label: "赤纬 Dec.", Value: "-05°59'26.5\""},
|
||||
{Label: "掩甚", Value: "04:28:23", Detail: "90.9407°E, 11.7283°N"},
|
||||
{Label: "视半径 S.D.", Value: "00°16'21.4\""},
|
||||
}
|
||||
if PanelBoxOverlaps(0, 0, 230, 200, "月亮(地心坐标)", rows) {
|
||||
t.Fatal("tall box reported overlapping rows")
|
||||
}
|
||||
if !PanelBoxOverlaps(0, 0, 230, 34, "月亮(地心坐标)", rows) {
|
||||
t.Fatal("box too short for its rows reported no overlap")
|
||||
}
|
||||
if !PanelBoxOverlaps(0, 0, 120, 200, "月亮(地心坐标)", rows) {
|
||||
t.Fatal("box too narrow for label and value reported no overlap")
|
||||
}
|
||||
if PanelBoxOverlaps(0, 0, 230, 200, "标题", nil) {
|
||||
t.Fatal("empty block reported an overlap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePanelBoxKeepsEveryTextInsideTheBlockWidth(t *testing.T) {
|
||||
row := PanelRow{Label: "地平视差 H.P.", Value: "00°16'21.4\""}
|
||||
var builder strings.Builder
|
||||
WritePanelBox(&builder, "panel", 0, 0, 170, 200, "月亮(地心坐标)", []PanelRow{row})
|
||||
document := builder.String()
|
||||
labelX := strings.Index(document, `x="12.000"`)
|
||||
if labelX < 0 {
|
||||
t.Fatalf("label is not drawn at the block padding: %s", document)
|
||||
}
|
||||
if !strings.Contains(document, `text-anchor="end"`) {
|
||||
t.Fatalf("value is not right-anchored: %s", document)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package svgchart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ellipsisMark 是截断标记;用单个全角省略号,避免与文本里的三个半角点混淆。
|
||||
const ellipsisMark = "…"
|
||||
|
||||
// estimatedTextRuneWidth 与 EstimatedTextWidth 的逐字符口径必须一致,否则折行结果会与占位判定矛盾。
|
||||
func estimatedTextRuneWidth(symbol rune, fontSize float64) float64 {
|
||||
if symbol < 128 {
|
||||
return 0.62 * fontSize
|
||||
}
|
||||
return 1.05 * fontSize
|
||||
}
|
||||
|
||||
// EllipsizeText 把单行文本裁到 maxWidth 内:放不下时保留前缀并追加省略号,全空的返回值表示连省略号都放不下。
|
||||
// EllipsizeText clamps one line to maxWidth, appending an ellipsis when the text does not fit;
|
||||
// an empty result means even the ellipsis does not fit.
|
||||
func EllipsizeText(value string, maxWidth, fontSize float64) string {
|
||||
if EstimatedTextWidth(value, fontSize) <= maxWidth {
|
||||
return value
|
||||
}
|
||||
return truncateWithMark(value, maxWidth, fontSize)
|
||||
}
|
||||
|
||||
// truncateWithMark 无条件追加省略号:被截断的行即使本身放得下,也必须标出后面还有内容。
|
||||
func truncateWithMark(value string, maxWidth, fontSize float64) string {
|
||||
markWidth := EstimatedTextWidth(ellipsisMark, fontSize)
|
||||
if markWidth > maxWidth {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
for len(runes) > 0 && EstimatedTextWidth(string(runes), fontSize)+markWidth > maxWidth {
|
||||
runes = runes[:len(runes)-1]
|
||||
}
|
||||
return strings.TrimRight(string(runes), " ") + ellipsisMark
|
||||
}
|
||||
|
||||
// WrapText 按 EstimatedTextWidth 保守折行:优先在空白处断开,单个词或全角文本按字符断开,空行不返回。
|
||||
// WrapText wraps conservatively by EstimatedTextWidth, preferring whitespace breaks; empty lines are dropped.
|
||||
func WrapText(value string, maxWidth, fontSize float64) []string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if EstimatedTextWidth(value, fontSize) <= maxWidth {
|
||||
return []string{value}
|
||||
}
|
||||
runes := []rune(value)
|
||||
lines := make([]string, 0, 4)
|
||||
for len(runes) > 0 {
|
||||
width := 0.0
|
||||
end := 0
|
||||
lastSpace := -1
|
||||
for end < len(runes) {
|
||||
nextWidth := width + estimatedTextRuneWidth(runes[end], fontSize)
|
||||
if nextWidth > maxWidth && end > 0 {
|
||||
break
|
||||
}
|
||||
width = nextWidth
|
||||
if unicode.IsSpace(runes[end]) {
|
||||
lastSpace = end
|
||||
}
|
||||
end++
|
||||
}
|
||||
if end < len(runes) && lastSpace > 0 {
|
||||
end = lastSpace
|
||||
}
|
||||
if end == 0 {
|
||||
end = 1
|
||||
}
|
||||
if line := strings.TrimSpace(string(runes[:end])); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
runes = runes[end:]
|
||||
for len(runes) > 0 && unicode.IsSpace(runes[0]) {
|
||||
runes = runes[1:]
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// TextLineLimit 返回高度预算 maxHeight 能容纳的行数,至少一行;口径与 EstimatedTextExtents 一致。
|
||||
// TextLineLimit reports how many lines fit in maxHeight, never less than one.
|
||||
func TextLineLimit(fontSize, lineHeight, maxHeight float64) int {
|
||||
if lineHeight <= 0 {
|
||||
lineHeight = 1.2 * fontSize
|
||||
}
|
||||
above, below := EstimatedTextExtents(fontSize)
|
||||
limit := int(math.Floor((maxHeight-above-below)/lineHeight)) + 1
|
||||
if limit < 1 {
|
||||
return 1
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// BaselineLineLimit 返回自 firstBaseline 起、底线 bottomLimit 之前能排下的行数,至少一行。
|
||||
// BaselineLineLimit reports how many lines fit below firstBaseline and above bottomLimit, never less than one.
|
||||
func BaselineLineLimit(fontSize, lineHeight, firstBaseline, bottomLimit float64) int {
|
||||
above, _ := EstimatedTextExtents(fontSize)
|
||||
return TextLineLimit(fontSize, lineHeight, bottomLimit-firstBaseline+above)
|
||||
}
|
||||
|
||||
// TruncateTextLines 按行数上限截断折行结果:截断时末行以省略号结尾并重新裁到 maxWidth 内,未截断时原样返回。
|
||||
// TruncateTextLines cuts lines to maxLines; a cut last line is ellipsized to maxWidth.
|
||||
func TruncateTextLines(lines []string, maxWidth, fontSize float64, maxLines int) []string {
|
||||
if len(lines) == 0 || maxLines <= 0 || len(lines) <= maxLines {
|
||||
return lines
|
||||
}
|
||||
kept := make([]string, maxLines)
|
||||
copy(kept, lines[:maxLines])
|
||||
kept[maxLines-1] = truncateWithMark(kept[maxLines-1], maxWidth, fontSize)
|
||||
return kept
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package svgchart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 折行与截断必须与 EstimatedTextWidth/EstimatedTextExtents 同口径:留下的每一行都不超宽,
|
||||
// 截断后的行数不超过高度预算,且末行以省略号结尾。
|
||||
|
||||
func textFits(t *testing.T, lines []string, maxWidth, fontSize float64) {
|
||||
t.Helper()
|
||||
for _, line := range lines {
|
||||
if width := EstimatedTextWidth(line, fontSize); width > maxWidth {
|
||||
t.Fatalf("line %q width %.1f exceeds %.1f", line, width, maxWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapTextKeepsEveryLineInsideWidth(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
value string
|
||||
maxWidth float64
|
||||
fontSize float64
|
||||
}{
|
||||
{name: "ascii words", value: strings.Repeat("alpha beta gamma ", 20), maxWidth: 220, fontSize: 11},
|
||||
{name: "chinese runs", value: strings.Repeat("月球掩星示意图说明", 30), maxWidth: 180, fontSize: 11},
|
||||
{name: "mixed", value: strings.Repeat("掩星 occultation 说明;", 40), maxWidth: 260, fontSize: 9},
|
||||
{name: "single word", value: strings.Repeat("A", 200), maxWidth: 120, fontSize: 10},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
lines := WrapText(test.value, test.maxWidth, test.fontSize)
|
||||
if len(lines) < 2 {
|
||||
t.Fatalf("lines = %d, want the text to be wrapped", len(lines))
|
||||
}
|
||||
textFits(t, lines, test.maxWidth, test.fontSize)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapTextKeepsShortTextOnOneLine(t *testing.T) {
|
||||
lines := WrapText(" 短说明 short note ", 400, 11)
|
||||
if len(lines) != 1 || lines[0] != "短说明 short note" {
|
||||
t.Fatalf("lines = %#v, want one trimmed line", lines)
|
||||
}
|
||||
if lines := WrapText(" ", 400, 11); lines != nil {
|
||||
t.Fatalf("blank text lines = %#v, want nil", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTextLinesMarksTheCut(t *testing.T) {
|
||||
lines := []string{"第一行", "第二行", "第三行", "第四行"}
|
||||
kept := TruncateTextLines(lines, 400, 11, 2)
|
||||
if len(kept) != 2 || kept[0] != "第一行" {
|
||||
t.Fatalf("kept = %#v, want the first two lines", kept)
|
||||
}
|
||||
if !strings.HasSuffix(kept[1], ellipsisMark) {
|
||||
t.Fatalf("last kept line = %q, want the ellipsis mark", kept[1])
|
||||
}
|
||||
if kept := TruncateTextLines(lines, 400, 11, 9); len(kept) != 4 || kept[3] != "第四行" {
|
||||
t.Fatalf("kept = %#v, want the untouched input", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateTextLinesEllipsizesTheLastLineToWidth(t *testing.T) {
|
||||
lines := []string{strings.Repeat("宽", 20), strings.Repeat("宽", 20)}
|
||||
kept := TruncateTextLines(lines, 60, 10, 1)
|
||||
if len(kept) != 1 || !strings.HasSuffix(kept[0], ellipsisMark) {
|
||||
t.Fatalf("kept = %#v, want one ellipsized line", kept)
|
||||
}
|
||||
textFits(t, kept, 60, 10)
|
||||
}
|
||||
|
||||
func TestTextLineLimitCountsExtents(t *testing.T) {
|
||||
// 3 行 11 号字、行距 15 的总高约 42.65,预算再少一点就只放得下 2 行。
|
||||
if limit := TextLineLimit(11, 15, 42.7); limit != 3 {
|
||||
t.Fatalf("limit = %d, want 3", limit)
|
||||
}
|
||||
if limit := TextLineLimit(11, 15, 42.6); limit != 2 {
|
||||
t.Fatalf("limit = %d, want 2", limit)
|
||||
}
|
||||
if limit := TextLineLimit(11, 15, 1); limit != 1 {
|
||||
t.Fatalf("limit = %d, want the one-line floor", limit)
|
||||
}
|
||||
// 基线口径:首行基线 100,第 2 行要排得下需要底线不低于 100+15+0.3*11。
|
||||
if limit := BaselineLineLimit(11, 15, 100, 118.4); limit != 2 {
|
||||
t.Fatalf("limit = %d, want 2", limit)
|
||||
}
|
||||
if limit := BaselineLineLimit(11, 15, 100, 118.2); limit != 1 {
|
||||
t.Fatalf("limit = %d, want 1", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEllipsizeTextStaysInsideWidth(t *testing.T) {
|
||||
value := strings.Repeat("长文本 ellipsis ", 30)
|
||||
clamped := EllipsizeText(value, 150, 11)
|
||||
if !strings.HasSuffix(clamped, ellipsisMark) {
|
||||
t.Fatalf("clamped = %q, want the ellipsis mark", clamped)
|
||||
}
|
||||
textFits(t, []string{clamped}, 150, 11)
|
||||
if short := EllipsizeText("短", 150, 11); short != "短" {
|
||||
t.Fatalf("short text = %q, want it untouched", short)
|
||||
}
|
||||
if empty := EllipsizeText(value, 1, 11); empty != "" {
|
||||
t.Fatalf("clamped = %q, want an empty string when even the mark does not fit", empty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user