fix(card): adapt to profile magnitude — abbreviate ticks, truncate long text (#9)

The stress test caught real overflows from profiles the author doesn't
have:

- Y-axis tick labels were raw integers. A user with 10,000+ yearly
  contributions or 1000+ monthly commits would render "10000" / "1000000"
  at text-anchor="end" against a ~28 px gutter — the digits spilled
  leftward past x=0. formatTick now abbreviates: 999→"999", 1500→"1.5k",
  12345→"12k", 1234567→"1.2M". No label exceeds 4 chars, all fit the
  gutter for every card that uses niceTicks.

- Profile details dumped Company / Location / Website / title verbatim,
  which works for the author (VNG, Ho Chi Minh, miti99.com) but not for
  40-char strings. Each row truncates at 40 runes; the title truncates at
  34. Uses a rune-aware truncate() helper hoisted out of top-starred-repos
  into svg.go so every list-style card can share it.

- Streak date range collapses to a single-year form ("Jan 2 — Dec 31")
  when start.Year() == end.Year() and to "YYYY — YYYY" across years. The
  previous "Jan 2 — Dec 31, 2025" format at 10 px × 21 chars pushed past
  the ~113 px column width.

The TestCardsFitFrame stress test was reading text-anchor and font-size
with a non-greedy regex that missed attributes whose position varied. It
now parses the opening <text> tag as a block and extracts each attribute
with its own regex, so text-anchor="end" / "middle" elements are no
longer false negatives. The check also estimates rendered width
(0.6 × font-size × len) and asserts the implied left/right edges stay in
the frame — catching exactly the class of bug the axis-tick case
represents.
This commit is contained in:
2026-04-19 09:34:38 +07:00
committed by GitHub
parent 64f1dd5459
commit 55421ebe3d
6 changed files with 137 additions and 24 deletions
+38 -2
View File
@@ -1,6 +1,7 @@
package card
import (
"fmt"
"math"
"strconv"
)
@@ -36,7 +37,42 @@ func niceTicks(max float64, targetTicks int) []float64 {
return out
}
// formatTick renders a float tick label. Integer-valued ticks drop decimals.
// formatTick renders a float tick label, abbreviating thousands / millions /
// billions so every possible y-axis label fits within ≤4 characters. The
// leftPad gutter of every chart card is sized for ≤4 chars at 10 px font,
// so anything wider would overflow past the card frame for busy profiles
// (1000+ monthly commits, 10k+ yearly contributions, etc).
//
// Examples:
//
// 999 -> "999"
// 1_000 -> "1k"
// 1_500 -> "1.5k"
// 12_345 -> "12k"
// 1_234_567 -> "1.2M"
func formatTick(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
if v == 0 {
return "0"
}
abs := math.Abs(v)
if abs < 1000 {
return strconv.FormatFloat(v, 'f', -1, 64)
}
var div float64
var suffix string
switch {
case abs < 1_000_000:
div, suffix = 1000, "k"
case abs < 1_000_000_000:
div, suffix = 1_000_000, "M"
default:
div, suffix = 1_000_000_000, "B"
}
n := v / div
// One decimal place only when it matters. 1.5k stays "1.5k", 10k stays
// "10k" not "10.0k", 500k stays "500k".
if math.Abs(n) >= 10 || n == math.Trunc(n) {
return strconv.FormatFloat(n, 'f', 0, 64) + suffix
}
return fmt.Sprintf("%.1f%s", n, suffix)
}
+62 -1
View File
@@ -199,6 +199,14 @@ func TestCardsFitFrame(t *testing.T) {
// be fragile against the Catmull-Rom Bezier output.
var attrCoord = regexp.MustCompile(`(?:x|y|x1|y1|x2|y2|cx|cy)="(-?\d+(?:\.\d+)?)"`)
// textBlock captures the opening <text …> tag attributes and the inner text.
// We parse individual attributes with separate regexes so attribute order
// doesn't matter.
var textBlock = regexp.MustCompile(`<text\s+([^>]*)>([^<]*)</text>`)
var attrX = regexp.MustCompile(`\bx="(-?\d+(?:\.\d+)?)"`)
var attrAnchor = regexp.MustCompile(`\btext-anchor="([^"]+)"`)
var attrFontSize = regexp.MustCompile(`\bfont-size="(\d+)"`)
func assertInFrame(t *testing.T, name, svg string) {
t.Helper()
const (
@@ -210,7 +218,6 @@ func assertInFrame(t *testing.T, name, svg string) {
if err != nil {
continue
}
// Distinguish x-ish vs y-ish by the first attr char.
isX := strings.HasPrefix(m[0], "x") || strings.HasPrefix(m[0], "cx")
limit := float64(maxY)
if isX {
@@ -220,6 +227,60 @@ func assertInFrame(t *testing.T, name, svg string) {
t.Errorf("%s: attribute %q value %v outside frame (limit %v)", name, m[0], v, limit)
}
}
// <text> elements: the x attribute is the anchor point, but the string
// actually extends outward from it. Right-anchored axis labels are the
// classic overflow trap — the attr x stays inside the frame while the
// rendered digits spill past x=0. Estimate width conservatively
// (0.6 × font-size per char; Segoe UI avg is ~0.55).
for _, m := range textBlock.FindAllStringSubmatch(svg, -1) {
attrs := m[1]
text := m[2]
xm := attrX.FindStringSubmatch(attrs)
if xm == nil {
continue
}
x, err := strconv.ParseFloat(xm[1], 64)
if err != nil {
continue
}
anchor := ""
if am := attrAnchor.FindStringSubmatch(attrs); am != nil {
anchor = am[1]
}
fontSize := 12.0
if fm := attrFontSize.FindStringSubmatch(attrs); fm != nil {
if f, err := strconv.ParseFloat(fm[1], 64); err == nil {
fontSize = f
}
}
width := float64(runeLen(text)) * fontSize * 0.6
var left, right float64
switch anchor {
case "end":
left, right = x-width, x
case "middle":
left, right = x-width/2, x+width/2
default:
left, right = x, x+width
}
if left < -2 {
t.Errorf("%s: <text>%q at x=%v anchor=%q extends to left=%.1f (outside frame)", name, text, x, anchor, left)
}
if right > maxX+2 {
t.Errorf("%s: <text>%q at x=%v anchor=%q extends to right=%.1f (outside frame)", name, text, x, anchor, right)
}
}
}
func runeLen(s string) int {
n := 0
for range s {
n++
}
return n
}
// adversarialProfile exercises every card against the worst-case inputs a
+12 -4
View File
@@ -41,6 +41,11 @@ func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
// scale factor to fit 16x16 octicon into iconSize box
scale := float64(iconSize) / 16.0
// Row text starts at rowX+iconSize+8 = 40; the right edge safety margin
// is width-10 = 330. Space = 290 px; at 12 px font × 0.6 char width =
// 7.2 px/char, so ~40 characters fit. Clamping here means a 200-char
// company name can't push the frame open.
const rowMaxChars = 40
for i, r := range rows {
y := rowY0 + i*rowDY
// icon glyph: translate to row position, scale down, fill with muted.
@@ -48,7 +53,7 @@ func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
<g transform="translate(%d,%.2f) scale(%.3f)" fill="%s">%s</g>
<text x="%d" y="%d" font-size="12" fill="%s">%s</text>`,
rowX, float64(y-iconSize+2), scale, t.Muted, r.icon,
rowX+iconSize+8, y, t.Text, escapeXML(r.value))
rowX+iconSize+8, y, t.Text, escapeXML(truncate(r.value, rowMaxChars)))
}
b.WriteString(footer)
@@ -56,12 +61,15 @@ func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
}
// cardTitle mirrors github-profile-summary-cards: "login (Name)" when name is
// set, "login" otherwise.
// set, "login" otherwise. Clamped to 34 runes — the title renders at 15 px
// weight 600, so ~35 chars is the max that reliably fits in 3402010=310
// px of title row.
func cardTitle(p *github.Profile) string {
raw := p.Login
if p.Name != "" {
return p.Login + " (" + p.Name + ")"
raw = p.Login + " (" + p.Name + ")"
}
return p.Login
return truncate(raw, 34)
}
func buildProfileRows(p *github.Profile) []profileRow {
+11 -7
View File
@@ -126,18 +126,22 @@ func activeDaysDetail(active, total int) string {
return fmt.Sprintf("of %s total (%d%%)", formatInt(total), pct)
}
// streakRange formats the open/close dates of a streak as "Mon 2 — Wed 11"
// when both are present. Returns "" when the streak is zero-length so the
// card renders cleanly.
// streakRange formats the open/close dates of a streak for the small detail
// line. Each column is ~113 px wide at font-size 10 (≤ ~18 chars), so we
// aggressively drop year and redundant parts when they'd blow the budget.
//
// same day -> "Jan 2 2025"
// same year -> "Jan 2 — Dec 31"
// different -> "2024 — 2026"
func streakRange(start, end time.Time) string {
if start.IsZero() || end.IsZero() {
return ""
}
if start.Equal(end) {
return start.Format("Jan 2, 2006")
return start.Format("Jan 2 2006")
}
if start.Year() == end.Year() {
return start.Format("Jan 2") + " — " + end.Format("Jan 2, 2006")
if start.Year() != end.Year() {
return fmt.Sprintf("%d — %d", start.Year(), end.Year())
}
return start.Format("Jan 2006") + " — " + end.Format("Jan 2006")
return start.Format("Jan 2") + " — " + end.Format("Jan 2")
}
+13
View File
@@ -18,6 +18,19 @@ func escapeXML(s string) string {
return r.Replace(s)
}
// truncate returns s clamped to at most n runes, appending "…" when the
// input was longer. Operates on runes so multi-byte strings (emoji, CJK)
// don't split mid-codepoint. Used by every row-style card to make sure a
// pathological name / company / location can't push the card's right edge
// out past the 340 px frame.
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimRight(string(r[:n-1]), ".") + "…"
}
// formatInt renders n with thousands separators (e.g. 12345 → "12,345").
func formatInt(n int) string {
neg := n < 0
+1 -10
View File
@@ -59,7 +59,7 @@ func (topStarredReposCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error)
if langColor == "" {
langColor = t.Accent
}
name := truncateName(r.Name, nameMax)
name := truncate(r.Name, nameMax)
fmt.Fprintf(&b, `
<circle cx="%d" cy="%d" r="4" fill="%s"/>
@@ -100,12 +100,3 @@ func ownedNonForkRepos(repos []github.RepoInfo) []github.RepoInfo {
return out
}
// truncateName trims to n runes and appends an ellipsis. Operates on runes
// so a multi-byte name (e.g. emoji) doesn't mid-cut.
func truncateName(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return strings.TrimRight(string(r[:n-1]), ".") + "…"
}