fix: Handle Unicode/emoji in plain text CV with proper centering
- Fix infinite loop caused by byte-based string slicing on multi-byte chars - Use rune-based operations for proper Unicode handling - Add template functions: center, separator, box - Box function creates rounded corners with dynamic width - Account for emoji display width (2 chars) in calculations - Make line width configurable via plainTextLineWidth constant
This commit is contained in:
@@ -10,6 +10,9 @@ import (
|
|||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Plain text configuration
|
||||||
|
const plainTextLineWidth = 80
|
||||||
|
|
||||||
// Text-based browsers and CLI tools that should get plain text
|
// Text-based browsers and CLI tools that should get plain text
|
||||||
var textBrowsers = []string{
|
var textBrowsers = []string{
|
||||||
"curl",
|
"curl",
|
||||||
@@ -80,13 +83,63 @@ func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add base URL and icons setting
|
// Add base URL, icons setting, and line width
|
||||||
data["BaseURL"] = h.serverAddr
|
data["BaseURL"] = h.serverAddr
|
||||||
data["Icons"] = showIcons
|
data["Icons"] = showIcons
|
||||||
|
data["LineWidth"] = plainTextLineWidth
|
||||||
|
|
||||||
// Load and parse the plain text template
|
// Template functions for text formatting
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
// center centers text within the line width
|
||||||
|
"center": func(text string) string {
|
||||||
|
runes := []rune(text)
|
||||||
|
textLen := len(runes)
|
||||||
|
if textLen >= plainTextLineWidth {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
padding := (plainTextLineWidth - textLen) / 2
|
||||||
|
return strings.Repeat(" ", padding) + text
|
||||||
|
},
|
||||||
|
// separator creates a centered separator line
|
||||||
|
"separator": func(char string, length int) string {
|
||||||
|
line := strings.Repeat(char, length)
|
||||||
|
padding := (plainTextLineWidth - length) / 2
|
||||||
|
return strings.Repeat(" ", padding) + line
|
||||||
|
},
|
||||||
|
// box creates a centered box frame with rounded corners around text
|
||||||
|
"box": func(text string) string {
|
||||||
|
// Calculate visual width (emojis display as 2 chars wide)
|
||||||
|
visualWidth := 0
|
||||||
|
for _, r := range text {
|
||||||
|
if r > 0x1F000 { // Emoji range
|
||||||
|
visualWidth += 2
|
||||||
|
} else {
|
||||||
|
visualWidth++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
innerPadding := 3 // spaces on each side inside the box
|
||||||
|
boxWidth := visualWidth + (innerPadding * 2) + 2 // padding both sides + 2 border chars
|
||||||
|
topBottom := "╭" + strings.Repeat("─", boxWidth-2) + "╮"
|
||||||
|
middle := "│" + strings.Repeat(" ", innerPadding) + text + strings.Repeat(" ", innerPadding) + "│"
|
||||||
|
bottom := "╰" + strings.Repeat("─", boxWidth-2) + "╯"
|
||||||
|
|
||||||
|
// Center each line (using visual width for proper alignment)
|
||||||
|
centerLine := func(line string, lineVisualWidth int) string {
|
||||||
|
if lineVisualWidth >= plainTextLineWidth {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
padding := (plainTextLineWidth - lineVisualWidth) / 2
|
||||||
|
return strings.Repeat(" ", padding) + line
|
||||||
|
}
|
||||||
|
|
||||||
|
return centerLine(topBottom, boxWidth) + "\n" + centerLine(middle, boxWidth) + "\n" + centerLine(bottom, boxWidth)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and parse the plain text template with custom functions
|
||||||
tmplPath := filepath.Join("templates", "cv-text.txt")
|
tmplPath := filepath.Join("templates", "cv-text.txt")
|
||||||
tmpl, err := template.New("cv-text.txt").ParseFiles(tmplPath)
|
tmpl, err := template.New("cv-text.txt").Funcs(funcMap).ParseFiles(tmplPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("PlainText: Failed to load template: %v", err)
|
log.Printf("PlainText: Failed to load template: %v", err)
|
||||||
http.Error(w, "Failed to load template", http.StatusInternalServerError)
|
http.Error(w, "Failed to load template", http.StatusInternalServerError)
|
||||||
@@ -142,8 +195,11 @@ func cleanPlainText(text string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wrapLine wraps a single line to maxLength, preserving leading whitespace
|
// wrapLine wraps a single line to maxLength, preserving leading whitespace
|
||||||
|
// Uses runes for proper Unicode/emoji handling
|
||||||
func wrapLine(line string, maxLength int) []string {
|
func wrapLine(line string, maxLength int) []string {
|
||||||
if len(line) <= maxLength {
|
runes := []rune(line)
|
||||||
|
|
||||||
|
if len(runes) <= maxLength {
|
||||||
return []string{line}
|
return []string{line}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +210,7 @@ func wrapLine(line string, maxLength int) []string {
|
|||||||
|
|
||||||
// Detect leading whitespace for continuation lines
|
// Detect leading whitespace for continuation lines
|
||||||
indent := ""
|
indent := ""
|
||||||
for _, r := range line {
|
for _, r := range runes {
|
||||||
if r == ' ' || r == '\t' {
|
if r == ' ' || r == '\t' {
|
||||||
indent += string(r)
|
indent += string(r)
|
||||||
} else {
|
} else {
|
||||||
@@ -169,7 +225,7 @@ func wrapLine(line string, maxLength int) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var wrapped []string
|
var wrapped []string
|
||||||
remaining := line
|
remaining := runes
|
||||||
|
|
||||||
for len(remaining) > maxLength {
|
for len(remaining) > maxLength {
|
||||||
// Find last space before maxLength
|
// Find last space before maxLength
|
||||||
@@ -182,16 +238,25 @@ func wrapLine(line string, maxLength int) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no space found, force break at maxLength
|
// If no space found, force break at maxLength
|
||||||
if breakPoint == maxLength && !strings.Contains(remaining[:maxLength], " ") {
|
if breakPoint == maxLength {
|
||||||
breakPoint = maxLength
|
hasSpace := false
|
||||||
|
for i := 0; i < maxLength; i++ {
|
||||||
|
if remaining[i] == ' ' {
|
||||||
|
hasSpace = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasSpace {
|
||||||
|
breakPoint = maxLength
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapped = append(wrapped, remaining[:breakPoint])
|
wrapped = append(wrapped, string(remaining[:breakPoint]))
|
||||||
remaining = indent + strings.TrimLeft(remaining[breakPoint:], " ")
|
remaining = []rune(indent + strings.TrimLeft(string(remaining[breakPoint:]), " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(remaining) > 0 {
|
if len(remaining) > 0 {
|
||||||
wrapped = append(wrapped, remaining)
|
wrapped = append(wrapped, string(remaining))
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
================================================================================
|
================================================================================
|
||||||
{{if .Icons}} 📄 CURRICULUM VITAE
|
{{if .Icons}}{{center "📄 CURRICULUM VITAE"}}
|
||||||
{{else}} CURRICULUM VITAE
|
{{else}}{{center "CURRICULUM VITAE"}}
|
||||||
{{end}}================================================================================
|
{{end}}================================================================================
|
||||||
|
|
||||||
{{if .Icons}} 👤 {{.CV.Personal.Name}}
|
{{if .Icons}}{{center (printf "👤 %s" .CV.Personal.Name)}}
|
||||||
{{else}} {{.CV.Personal.Name}}
|
{{else}}{{center .CV.Personal.Name}}
|
||||||
|
{{end}}
|
||||||
|
{{if .Icons}}{{box (printf "💼 %s" .CV.Personal.Title)}}
|
||||||
|
{{else}}{{box .CV.Personal.Title}}
|
||||||
{{end}}
|
{{end}}
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ {{.CV.Personal.Title}} │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
|
|
||||||
- {{if .Icons}}📍{{else}}Location:{{end}} {{.CV.Personal.Location}}
|
- {{if .Icons}}📍{{else}}Location:{{end}} {{.CV.Personal.Location}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user