From 5f85a7cc8d439ccd811b236ee75b254cd1c412c1 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Sun, 30 Nov 2025 17:15:44 +0000 Subject: [PATCH] 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 --- internal/handlers/cv_text.go | 87 +++++++++++++++++++++++++++++++----- templates/cv-text.txt | 14 +++--- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/internal/handlers/cv_text.go b/internal/handlers/cv_text.go index 8fe5dc0..9a8d0ea 100644 --- a/internal/handlers/cv_text.go +++ b/internal/handlers/cv_text.go @@ -10,6 +10,9 @@ import ( "text/template" ) +// Plain text configuration +const plainTextLineWidth = 80 + // Text-based browsers and CLI tools that should get plain text var textBrowsers = []string{ "curl", @@ -80,13 +83,63 @@ func (h *CVHandler) PlainText(w http.ResponseWriter, r *http.Request) { return } - // Add base URL and icons setting + // Add base URL, icons setting, and line width data["BaseURL"] = h.serverAddr 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") - tmpl, err := template.New("cv-text.txt").ParseFiles(tmplPath) + tmpl, err := template.New("cv-text.txt").Funcs(funcMap).ParseFiles(tmplPath) if err != nil { log.Printf("PlainText: Failed to load template: %v", err) 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 +// Uses runes for proper Unicode/emoji handling func wrapLine(line string, maxLength int) []string { - if len(line) <= maxLength { + runes := []rune(line) + + if len(runes) <= maxLength { return []string{line} } @@ -154,7 +210,7 @@ func wrapLine(line string, maxLength int) []string { // Detect leading whitespace for continuation lines indent := "" - for _, r := range line { + for _, r := range runes { if r == ' ' || r == '\t' { indent += string(r) } else { @@ -169,7 +225,7 @@ func wrapLine(line string, maxLength int) []string { } var wrapped []string - remaining := line + remaining := runes for len(remaining) > 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 breakPoint == maxLength && !strings.Contains(remaining[:maxLength], " ") { - breakPoint = maxLength + if 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]) - remaining = indent + strings.TrimLeft(remaining[breakPoint:], " ") + wrapped = append(wrapped, string(remaining[:breakPoint])) + remaining = []rune(indent + strings.TrimLeft(string(remaining[breakPoint:]), " ")) } if len(remaining) > 0 { - wrapped = append(wrapped, remaining) + wrapped = append(wrapped, string(remaining)) } return wrapped diff --git a/templates/cv-text.txt b/templates/cv-text.txt index de72e1b..ca7994c 100644 --- a/templates/cv-text.txt +++ b/templates/cv-text.txt @@ -1,14 +1,14 @@ ================================================================================ -{{if .Icons}} 📄 CURRICULUM VITAE -{{else}} CURRICULUM VITAE +{{if .Icons}}{{center "📄 CURRICULUM VITAE"}} +{{else}}{{center "CURRICULUM VITAE"}} {{end}}================================================================================ -{{if .Icons}} 👤 {{.CV.Personal.Name}} -{{else}} {{.CV.Personal.Name}} +{{if .Icons}}{{center (printf "👤 %s" .CV.Personal.Name)}} +{{else}}{{center .CV.Personal.Name}} +{{end}} +{{if .Icons}}{{box (printf "💼 %s" .CV.Personal.Title)}} +{{else}}{{box .CV.Personal.Title}} {{end}} - ┌─────────────────────────────────────────────┐ - │ {{.CV.Personal.Title}} │ - └─────────────────────────────────────────────┘ - {{if .Icons}}📍{{else}}Location:{{end}} {{.CV.Personal.Location}}