From 55421ebe3d81f1bc856fb2324bdd1ad133cdb305 Mon Sep 17 00:00:00 2001 From: Tien Nguyen Minh Date: Sun, 19 Apr 2026 09:34:38 +0700 Subject: [PATCH] =?UTF-8?q?fix(card):=20adapt=20to=20profile=20magnitude?= =?UTF-8?q?=20=E2=80=94=20abbreviate=20ticks,=20truncate=20long=20text=20(?= =?UTF-8?q?#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- internal/card/axis.go | 40 ++++++++++++++++++- internal/card/card_test.go | 63 +++++++++++++++++++++++++++++- internal/card/profile.go | 16 ++++++-- internal/card/streak.go | 18 +++++---- internal/card/svg.go | 13 ++++++ internal/card/top_starred_repos.go | 11 +----- 6 files changed, 137 insertions(+), 24 deletions(-) diff --git a/internal/card/axis.go b/internal/card/axis.go index 70c90dd0..61fc86bf 100644 --- a/internal/card/axis.go +++ b/internal/card/axis.go @@ -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) } diff --git a/internal/card/card_test.go b/internal/card/card_test.go index b8d36c10..dc770f93 100644 --- a/internal/card/card_test.go +++ b/internal/card/card_test.go @@ -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 tag attributes and the inner text. +// We parse individual attributes with separate regexes so attribute order +// doesn't matter. +var textBlock = regexp.MustCompile(`]*)>([^<]*)`) +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) } } + + // 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: %q at x=%v anchor=%q extends to left=%.1f (outside frame)", name, text, x, anchor, left) + } + if right > maxX+2 { + t.Errorf("%s: %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 diff --git a/internal/card/profile.go b/internal/card/profile.go index d330d7a1..2b06d791 100644 --- a/internal/card/profile.go +++ b/internal/card/profile.go @@ -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) { %s %s`, 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 340−20−10=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 { diff --git a/internal/card/streak.go b/internal/card/streak.go index b8ce5fc3..adebce92 100644 --- a/internal/card/streak.go +++ b/internal/card/streak.go @@ -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") } diff --git a/internal/card/svg.go b/internal/card/svg.go index 839bfb16..ff0dbeee 100644 --- a/internal/card/svg.go +++ b/internal/card/svg.go @@ -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 diff --git a/internal/card/top_starred_repos.go b/internal/card/top_starred_repos.go index c21cd6d3..88fc4de7 100644 --- a/internal/card/top_starred_repos.go +++ b/internal/card/top_starred_repos.go @@ -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, ` @@ -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]), ".") + "…" -}