Files
soft_usite/pkg/services/xlsx.go
T
Lizandro GuarnizoandClaude Sonnet 5 62c644b815 feat(portal): descarga del cronograma del proyecto en Excel
El cliente ya veía el roadmap en pantalla pero no tenía cómo pasárselo a
alguien que no entra al portal.

El .xlsx se genera con archive/zip + encoding/xml de la stdlib en vez de
sumar una dependencia de Excel para una sola pantalla. Un CSV renombrado
no servía: en Excel en español el separador y los acentos se rompen, y
esto es un archivo que el cliente le entrega a un tercero.

Detalles que hacen que Excel lo acepte y se lea bien: inline strings (sin
tabla sharedStrings, así ningún índice puede apuntar a la cadena
equivocada), encabezado en negrita y congelado, anchos de columna según
el contenido, y nombre de hoja saneado (Excel rechaza el archivo si pasa
de 31 caracteres o trae : \ / ? * [ ]).

El endpoint repite el chequeo de acceso de PortalProyecto: sin eso,
cualquiera con sesión de portal se bajaba el cronograma de otro cliente
adivinando el slug.

Verificado abriendo el archivo generado con openpyxl: 6 partes, CRC OK,
XML bien formado en todas, acentos y CJK intactos, negrita y panel
congelado donde corresponde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-14 22:12:45 -05:00

183 lines
6.6 KiB
Go

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(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">`)
// Congelar el encabezado: en un cronograma largo, perderlo al scrollear
// es lo primero que molesta.
b.WriteString(`<sheetViews><sheetView workbookViewId="0"><pane ySplit="1" topLeftCell="A2" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews>`)
b.WriteString(`<sheetFormatPr defaultRowHeight="15"/>`)
b.WriteString(anchoColumnas(filas))
b.WriteString(`<sheetData>`)
for i, fila := range filas {
estilo := ""
if i == 0 {
estilo = ` s="1"` // encabezado en negrita
}
fmt.Fprintf(&b, `<row r="%d">`, i+1)
for j, celda := range fila {
fmt.Fprintf(&b,
`<c r="%s%d" t="inlineStr"%s><is><t xml:space="preserve">%s</t></is></c>`,
columna(j), i+1, estilo, escaparXML(celda))
}
b.WriteString(`</row>`)
}
b.WriteString(`</sheetData></worksheet>`)
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(`<cols>`)
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, `<col min="%d" max="%d" width="%d" customWidth="1"/>`, j+1, j+1, a+2)
}
b.WriteString(`</cols>`)
return b.String()
}
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>`
const relsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`
const workbookXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="%s" sheetId="1" r:id="rId1"/></sheets>
</workbook>`
const workbookRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`
// Dos estilos: 0 normal, 1 negrita (el encabezado).
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
<fills count="1"><fill><patternFill patternType="none"/></fill></fills>
<borders count="1"><border/></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
</styleSheet>`