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>
183 lines
6.6 KiB
Go
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>`
|