feat: 完善日月食与月掩几何链路并扩展历法接口

- 新增日月食中心带、偏食带、阴影足迹、等时线、食分线及升落边界计算,支持极区与混合食拓扑
- 新增日食单时刻阴影求解器、站心状态查询、批量采样和 ΔT 覆盖接口
- 重构恒星与行星月掩路径,补充有限盘面接触、站心修正、掩带宽度、极区投影及升落边界
- 扩展 SVG 与 GeoJSON 输出,支持详细面板、全球/极区/地球投影、边界闭合、时间标记和拓扑签名
- 扩展日月食候选搜索、局地搜索、沙罗序列预计算与范围外推,补充系列锚点和成员一致性校验
- 补齐古历纪年、儒略历独有闰日、多公历候选、历法改革跨日及精确日期运算接口
- 优化 ΔT、章动、恒星时、月球地平线、事件根搜索和本地星历缓存,降低重复计算开销并提升边界稳定
This commit is contained in:
2026-09-17 12:27:40 +08:00
parent 9ee2163cc7
commit 2bf8478639
428 changed files with 85981 additions and 7998 deletions
+127
View File
@@ -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
}
+162
View File
@@ -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
}
+75
View File
@@ -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)
}
}
+118
View File
@@ -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
}
+108
View File
@@ -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)
}
}