package services
import (
"archive/zip"
"encoding/xml"
"fmt"
"io"
"strings"
)
// Un .xlsx es un zip con unos pocos XML dentro. Generarlo con archive/zip
// evita sumar una dependencia de Excel para una sola pantalla de descarga.
//
// Se usan inline strings (t="inlineStr") en vez de la tabla sharedStrings:
// ocupa un poco más, pero ahorra una parte entera del archivo y elimina la
// posibilidad de que un índice quede apuntando a la cadena equivocada.
//
// ponytail: solo texto, sin tipos ni formato de celda. Las fechas se
// escriben ya formateadas como texto — si alguna vez hace falta ordenar o
// hacer cuentas dentro de Excel, ahí sí conviene excelize.
// EscribirXLSX vuelca las filas en una hoja. La primera fila se toma como
// encabezado (queda en negrita y congelada).
func EscribirXLSX(w io.Writer, nombreHoja string, filas [][]string) error {
if nombreHoja == "" {
nombreHoja = "Hoja1"
}
// Excel rechaza el archivo si el nombre de hoja pasa de 31 caracteres o
// trae : \ / ? * [ ]
nombreHoja = limpiarNombreHoja(nombreHoja)
z := zip.NewWriter(w)
partes := []struct{ nombre, cuerpo string }{
{"[Content_Types].xml", contentTypesXML},
{"_rels/.rels", relsXML},
{"xl/workbook.xml", fmt.Sprintf(workbookXML, escaparXML(nombreHoja))},
{"xl/_rels/workbook.xml.rels", workbookRelsXML},
{"xl/styles.xml", stylesXML},
{"xl/worksheets/sheet1.xml", hojaXML(filas)},
}
for _, p := range partes {
f, err := z.Create(p.nombre)
if err != nil {
return err
}
if _, err := io.WriteString(f, p.cuerpo); err != nil {
return err
}
}
return z.Close()
}
func limpiarNombreHoja(n string) string {
n = strings.Map(func(r rune) rune {
if strings.ContainsRune(`:\/?*[]`, r) {
return '-'
}
return r
}, n)
if len([]rune(n)) > 31 {
n = string([]rune(n)[:31])
}
return n
}
// columna convierte 0 → A, 25 → Z, 26 → AA.
func columna(i int) string {
nombre := ""
for i >= 0 {
nombre = string(rune('A'+i%26)) + nombre
i = i/26 - 1
}
return nombre
}
func escaparXML(s string) string {
var b strings.Builder
// xml.EscapeText también neutraliza los caracteres de control que Excel
// rechaza, que es justo lo que puede venir en un texto pegado por el
// usuario.
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
func hojaXML(filas [][]string) string {
var b strings.Builder
b.WriteString(``)
b.WriteString(``)
// Congelar el encabezado: en un cronograma largo, perderlo al scrollear
// es lo primero que molesta.
b.WriteString(``)
b.WriteString(``)
b.WriteString(anchoColumnas(filas))
b.WriteString(``)
for i, fila := range filas {
estilo := ""
if i == 0 {
estilo = ` s="1"` // encabezado en negrita
}
fmt.Fprintf(&b, ``, i+1)
for j, celda := range fila {
fmt.Fprintf(&b,
`%s`,
columna(j), i+1, estilo, escaparXML(celda))
}
b.WriteString(`
`)
}
b.WriteString(``)
return b.String()
}
// anchoColumnas estima el ancho por el contenido más largo. Sin esto todo
// sale en el ancho por defecto y el cronograma se lee como una columna de
// almohadillas.
func anchoColumnas(filas [][]string) string {
if len(filas) == 0 {
return ""
}
maxCols := 0
for _, f := range filas {
if len(f) > maxCols {
maxCols = len(f)
}
}
anchos := make([]int, maxCols)
for _, f := range filas {
for j, celda := range f {
if n := len([]rune(celda)); n > anchos[j] {
anchos[j] = n
}
}
}
var b strings.Builder
b.WriteString(``)
for j, a := range anchos {
if a < 10 {
a = 10
}
if a > 60 { // más allá de esto conviene que Excel corte, no scrollear al infinito
a = 60
}
fmt.Fprintf(&b, ``, j+1, j+1, a+2)
}
b.WriteString(``)
return b.String()
}
const contentTypesXML = `
`
const relsXML = `
`
const workbookXML = `
`
const workbookRelsXML = `
`
// Dos estilos: 0 normal, 1 negrita (el encabezado).
const stylesXML = `
`