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 = ` `