+1
This commit is contained in:
@@ -0,0 +1,626 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fogleman/gg"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/gofont/gobold"
|
||||
"golang.org/x/image/font/gofont/goregular"
|
||||
"golang.org/x/image/font/opentype"
|
||||
"golang.org/x/text/currency"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/message"
|
||||
)
|
||||
|
||||
const (
|
||||
imageWidth = 1200
|
||||
imageHeight = 630
|
||||
)
|
||||
|
||||
type shareData struct {
|
||||
Name string `json:"n"`
|
||||
RealAmount json.Number `json:"r"`
|
||||
GoalAmount json.Number `json:"g"`
|
||||
Currency string `json:"c"`
|
||||
TargetDate string `json:"td"`
|
||||
EstimateCompletion string `json:"ec"`
|
||||
PrivateMode bool `json:"p"`
|
||||
}
|
||||
|
||||
type shareView struct {
|
||||
Title string
|
||||
Description string
|
||||
OGImageURL string
|
||||
ShareURL string
|
||||
GoalName string
|
||||
CurrentAmount string
|
||||
TargetAmount string
|
||||
PercentText string
|
||||
ProgressPercent float64
|
||||
TargetDateLabel string
|
||||
EstimateDateLabel string
|
||||
PrivateMode bool
|
||||
FormattedGoalAmount string
|
||||
}
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8090"
|
||||
}
|
||||
|
||||
http.HandleFunc("/share", shareHandler)
|
||||
http.HandleFunc("/og", ogHandler)
|
||||
|
||||
log.Printf("og-service listening on :%s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||
}
|
||||
|
||||
func shareHandler(w http.ResponseWriter, r *http.Request) {
|
||||
dataParam := r.URL.Query().Get("data")
|
||||
if dataParam == "" {
|
||||
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := decodeData(dataParam)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
view := buildShareView(payload, r, dataParam)
|
||||
html := renderShareHTML(view)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func ogHandler(w http.ResponseWriter, r *http.Request) {
|
||||
dataParam := r.URL.Query().Get("data")
|
||||
if dataParam == "" {
|
||||
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := decodeData(dataParam)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pngBytes, err := renderOGImage(payload)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to render image", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_, _ = w.Write(pngBytes)
|
||||
}
|
||||
|
||||
func decodeData(param string) (shareData, error) {
|
||||
var data shareData
|
||||
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(param)
|
||||
if err != nil {
|
||||
decoded, err = base64.URLEncoding.DecodeString(param)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(bytes.NewReader(decoded))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
return data, err
|
||||
}
|
||||
|
||||
data.Name = strings.TrimSpace(data.Name)
|
||||
data.Currency = strings.ToUpper(strings.TrimSpace(data.Currency))
|
||||
if data.Currency == "" {
|
||||
data.Currency = "USD"
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func buildShareView(data shareData, r *http.Request, rawParam string) shareView {
|
||||
goalAmount, _ := numberToFloat(data.GoalAmount)
|
||||
realAmount, _ := numberToFloat(data.RealAmount)
|
||||
|
||||
percent := 0.0
|
||||
if goalAmount > 0 {
|
||||
percent = (realAmount / goalAmount) * 100
|
||||
}
|
||||
|
||||
percentText := "Progress unavailable"
|
||||
if goalAmount > 0 {
|
||||
percentText = fmt.Sprintf("%.0f%% complete", percent)
|
||||
}
|
||||
|
||||
currencyTag := localeForCurrency(data.Currency)
|
||||
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
|
||||
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
|
||||
|
||||
currentAmount := formattedReal
|
||||
if data.PrivateMode {
|
||||
currentAmount = "Hidden"
|
||||
}
|
||||
|
||||
goalName := data.Name
|
||||
if goalName == "" {
|
||||
goalName = "Investment Goal"
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("%s toward %s", percentText, formattedGoal)
|
||||
if data.PrivateMode {
|
||||
description = fmt.Sprintf("Goal progress: %s", percentText)
|
||||
}
|
||||
|
||||
baseURL := buildBaseURL(r)
|
||||
shareURL := fmt.Sprintf("%s/share?data=%s", baseURL, url.QueryEscape(rawParam))
|
||||
ogImageURL := fmt.Sprintf("%s/og?data=%s", baseURL, url.QueryEscape(rawParam))
|
||||
|
||||
return shareView{
|
||||
Title: fmt.Sprintf("Portfolio Journal — %s", goalName),
|
||||
Description: description,
|
||||
OGImageURL: ogImageURL,
|
||||
ShareURL: shareURL,
|
||||
GoalName: goalName,
|
||||
CurrentAmount: currentAmount,
|
||||
TargetAmount: formattedGoal,
|
||||
PercentText: percentText,
|
||||
ProgressPercent: percent,
|
||||
TargetDateLabel: formatDateLabel("Target", data.TargetDate),
|
||||
EstimateDateLabel: formatDateLabel("Est. completion", data.EstimateCompletion),
|
||||
PrivateMode: data.PrivateMode,
|
||||
FormattedGoalAmount: formattedGoal,
|
||||
}
|
||||
}
|
||||
|
||||
func renderShareHTML(view shareView) string {
|
||||
htmlTemplate := `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>` + escapeHTML(view.Title) + `</title>
|
||||
|
||||
<meta name="title" content="` + escapeHTML(view.Title) + `">
|
||||
<meta name="description" content="` + escapeHTML(view.Description) + `">
|
||||
<meta name="author" content="Portfolio Journal">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="` + escapeHTML(view.ShareURL) + `">
|
||||
<meta property="og:title" content="` + escapeHTML(view.Title) + `">
|
||||
<meta property="og:description" content="` + escapeHTML(view.Description) + `">
|
||||
<meta property="og:image" content="` + escapeHTML(view.OGImageURL) + `">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Portfolio Journal">
|
||||
<meta property="og:locale" content="en_US">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="` + escapeHTML(view.ShareURL) + `">
|
||||
<meta name="twitter:title" content="` + escapeHTML(view.Title) + `">
|
||||
<meta name="twitter:description" content="` + escapeHTML(view.Description) + `">
|
||||
<meta name="twitter:image" content="` + escapeHTML(view.OGImageURL) + `">
|
||||
|
||||
<meta name="apple-itunes-app" content="app-id=6744983373">
|
||||
<link rel="canonical" href="` + escapeHTML(view.ShareURL) + `">
|
||||
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.card {
|
||||
max-width: 620px;
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-radius: 24px;
|
||||
padding: 40px;
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.25);
|
||||
}
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.amounts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.amounts span {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.amounts strong {
|
||||
display: block;
|
||||
font-size: 1.5rem;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.progress {
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
overflow: hidden;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.progress .bar {
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
width: ` + fmt.Sprintf("%.2f", math.Max(0, math.Min(100, view.ProgressPercent))) + `%;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 18px;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cta {
|
||||
margin-top: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #fff;
|
||||
color: #5c5bd4;
|
||||
padding: 12px 20px;
|
||||
border-radius: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="eyebrow">Portfolio Journal</div>
|
||||
<h1>` + escapeHTML(view.GoalName) + `</h1>
|
||||
<div class="amounts">
|
||||
<div>
|
||||
<span>Current amount</span>
|
||||
<strong>` + escapeHTML(view.CurrentAmount) + `</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Goal amount</span>
|
||||
<strong>` + escapeHTML(view.TargetAmount) + `</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>` + escapeHTML(view.PercentText) + `</div>
|
||||
<div class="progress"><div class="bar"></div></div>
|
||||
<div class="meta">` + escapeHTML(joinLabels(view.TargetDateLabel, view.EstimateDateLabel)) + `</div>
|
||||
<a class="cta" href="https://apps.apple.com/app/portfolio-journal/id6744983373">Download on the App Store</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return htmlTemplate
|
||||
}
|
||||
|
||||
func renderOGImage(data shareData) ([]byte, error) {
|
||||
goalAmount, _ := numberToFloat(data.GoalAmount)
|
||||
realAmount, _ := numberToFloat(data.RealAmount)
|
||||
percent := 0.0
|
||||
if goalAmount > 0 {
|
||||
percent = (realAmount / goalAmount) * 100
|
||||
}
|
||||
|
||||
currencyTag := localeForCurrency(data.Currency)
|
||||
formattedGoal := formatCurrency(goalAmount, data.Currency, currencyTag)
|
||||
formattedReal := formatCurrency(realAmount, data.Currency, currencyTag)
|
||||
if data.PrivateMode {
|
||||
formattedReal = "Hidden"
|
||||
}
|
||||
|
||||
goalName := data.Name
|
||||
if goalName == "" {
|
||||
goalName = "Investment Goal"
|
||||
}
|
||||
|
||||
dc := gg.NewContext(imageWidth, imageHeight)
|
||||
|
||||
grad := gg.NewLinearGradient(0, 0, imageWidth, imageHeight)
|
||||
grad.AddColorStop(0, color.RGBA{0x66, 0x7e, 0xea, 0xff})
|
||||
grad.AddColorStop(1, color.RGBA{0x76, 0x4b, 0xa2, 0xff})
|
||||
|
||||
dc.SetFillStyle(grad)
|
||||
dc.DrawRectangle(0, 0, imageWidth, imageHeight)
|
||||
dc.Fill()
|
||||
|
||||
marginX := 80.0
|
||||
marginY := 80.0
|
||||
|
||||
// App icon placeholder
|
||||
dc.SetRGBA(1, 1, 1, 0.2)
|
||||
dc.DrawRoundedRectangle(marginX, marginY, 96, 96, 22)
|
||||
dc.Fill()
|
||||
|
||||
iconFace, err := loadFontFace(38, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(iconFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.9)
|
||||
dc.DrawStringAnchored("PJ", marginX+48, marginY+52, 0.5, 0.5)
|
||||
|
||||
labelFace, err := loadFontFace(20, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(labelFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.8)
|
||||
dc.DrawString("Portfolio Journal", marginX+120, marginY+34)
|
||||
|
||||
titleFace, err := loadFontFace(54, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(titleFace)
|
||||
dc.SetRGBA(1, 1, 1, 1)
|
||||
goalName = fitText(dc, goalName, imageWidth-160)
|
||||
dc.DrawStringWrapped(goalName, marginX, marginY+130, 0, 0, imageWidth-160, 1.3, gg.AlignLeft)
|
||||
|
||||
valueFace, err := loadFontFace(36, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
labelSmallFace, err := loadFontFace(18, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dc.SetFontFace(labelSmallFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.75)
|
||||
dc.DrawString("Current", marginX, marginY+260)
|
||||
dc.DrawString("Goal", marginX+420, marginY+260)
|
||||
|
||||
dc.SetFontFace(valueFace)
|
||||
dc.SetRGBA(1, 1, 1, 1)
|
||||
dc.DrawString(formattedReal, marginX, marginY+305)
|
||||
dc.DrawString(formattedGoal, marginX+420, marginY+305)
|
||||
|
||||
percentText := "Progress unavailable"
|
||||
if goalAmount > 0 {
|
||||
percentText = fmt.Sprintf("%.0f%% complete", percent)
|
||||
}
|
||||
if data.PrivateMode {
|
||||
percentText = strings.ReplaceAll(percentText, "complete", "of goal")
|
||||
}
|
||||
|
||||
dc.SetFontFace(labelSmallFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.85)
|
||||
dc.DrawString(percentText, marginX, marginY+360)
|
||||
|
||||
barWidth := 720.0
|
||||
barHeight := 18.0
|
||||
barX := marginX
|
||||
barY := marginY + 380
|
||||
progress := math.Max(0, math.Min(100, percent)) / 100
|
||||
|
||||
dc.SetRGBA(1, 1, 1, 0.25)
|
||||
dc.DrawRoundedRectangle(barX, barY, barWidth, barHeight, 9)
|
||||
dc.Fill()
|
||||
|
||||
dc.SetRGBA(1, 1, 1, 0.9)
|
||||
dc.DrawRoundedRectangle(barX, barY, barWidth*progress, barHeight, 9)
|
||||
dc.Fill()
|
||||
|
||||
meta := joinLabels(formatDateLabel("Target", data.TargetDate), formatDateLabel("Est. completion", data.EstimateCompletion))
|
||||
if meta != "" {
|
||||
dc.SetFontFace(labelSmallFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.7)
|
||||
dc.DrawString(meta, marginX, marginY+430)
|
||||
}
|
||||
|
||||
ctaFace, err := loadFontFace(20, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dc.SetFontFace(ctaFace)
|
||||
dc.SetRGBA(1, 1, 1, 0.9)
|
||||
dc.DrawString("Available on the App Store", marginX, imageHeight-70)
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := dc.EncodePNG(buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func numberToFloat(n json.Number) (float64, bool) {
|
||||
if n.String() == "" {
|
||||
return 0, false
|
||||
}
|
||||
value, err := n.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func formatCurrency(amount float64, code string, tag language.Tag) string {
|
||||
unit, err := currency.ParseISO(code)
|
||||
if err != nil {
|
||||
unit = currency.USD
|
||||
}
|
||||
printer := message.NewPrinter(tag)
|
||||
return printer.Sprintf("%v", currency.Amount(amount, unit))
|
||||
}
|
||||
|
||||
func localeForCurrency(code string) language.Tag {
|
||||
switch code {
|
||||
case "EUR":
|
||||
return language.MustParse("es-ES")
|
||||
case "GBP":
|
||||
return language.MustParse("en-GB")
|
||||
case "JPY":
|
||||
return language.MustParse("ja-JP")
|
||||
case "CNY":
|
||||
return language.MustParse("zh-CN")
|
||||
case "HKD":
|
||||
return language.MustParse("zh-HK")
|
||||
case "KRW":
|
||||
return language.MustParse("ko-KR")
|
||||
case "INR":
|
||||
return language.MustParse("en-IN")
|
||||
case "BRL":
|
||||
return language.MustParse("pt-BR")
|
||||
case "MXN":
|
||||
return language.MustParse("es-MX")
|
||||
case "ARS":
|
||||
return language.MustParse("es-AR")
|
||||
case "CLP":
|
||||
return language.MustParse("es-CL")
|
||||
case "COP":
|
||||
return language.MustParse("es-CO")
|
||||
case "CAD":
|
||||
return language.MustParse("en-CA")
|
||||
case "AUD":
|
||||
return language.MustParse("en-AU")
|
||||
case "NZD":
|
||||
return language.MustParse("en-NZ")
|
||||
case "CHF":
|
||||
return language.MustParse("de-CH")
|
||||
case "SEK":
|
||||
return language.MustParse("sv-SE")
|
||||
case "NOK":
|
||||
return language.MustParse("nb-NO")
|
||||
case "DKK":
|
||||
return language.MustParse("da-DK")
|
||||
case "PLN":
|
||||
return language.MustParse("pl-PL")
|
||||
case "CZK":
|
||||
return language.MustParse("cs-CZ")
|
||||
case "HUF":
|
||||
return language.MustParse("hu-HU")
|
||||
case "TRY":
|
||||
return language.MustParse("tr-TR")
|
||||
case "ZAR":
|
||||
return language.MustParse("en-ZA")
|
||||
case "AED":
|
||||
return language.MustParse("ar-AE")
|
||||
default:
|
||||
return language.MustParse("en-US")
|
||||
}
|
||||
}
|
||||
|
||||
func formatDateLabel(prefix, dateString string) string {
|
||||
if dateString == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := time.Parse("2006-01-02", dateString)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", prefix, parsed.Format("Jan 2, 2006"))
|
||||
}
|
||||
|
||||
func joinLabels(primary, secondary string) string {
|
||||
if primary == "" && secondary == "" {
|
||||
return ""
|
||||
}
|
||||
if primary == "" {
|
||||
return secondary
|
||||
}
|
||||
if secondary == "" {
|
||||
return primary
|
||||
}
|
||||
return primary + " • " + secondary
|
||||
}
|
||||
|
||||
func buildBaseURL(r *http.Request) string {
|
||||
proto := r.Header.Get("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
proto = "https"
|
||||
}
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
return fmt.Sprintf("%s://%s", proto, host)
|
||||
}
|
||||
|
||||
func escapeHTML(input string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
"\"", """,
|
||||
"'", "'",
|
||||
)
|
||||
return replacer.Replace(input)
|
||||
}
|
||||
|
||||
func loadFontFace(size float64, bold bool) (font.Face, error) {
|
||||
var fontBytes []byte
|
||||
if bold {
|
||||
fontBytes = gobold.TTF
|
||||
} else {
|
||||
fontBytes = goregular.TTF
|
||||
}
|
||||
parsed, err := opentype.Parse(fontBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
face, err := opentype.NewFace(parsed, &opentype.FaceOptions{
|
||||
Size: size,
|
||||
DPI: 72,
|
||||
Hinting: font.HintingFull,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return face, nil
|
||||
}
|
||||
|
||||
func fitText(dc *gg.Context, text string, maxWidth float64) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if width, _ := dc.MeasureString(trimmed); width <= maxWidth {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
runes := []rune(trimmed)
|
||||
for len(runes) > 0 {
|
||||
candidate := string(runes) + "…"
|
||||
if width, _ := dc.MeasureString(candidate); width <= maxWidth {
|
||||
return candidate
|
||||
}
|
||||
runes = runes[:len(runes)-1]
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
Reference in New Issue
Block a user