cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

XLSX: wrapped text, borders, row heights and merged cells both ways

Dami committed e39d5d9
Cell formats carry wrapText and a border from the styles part's
<borders>: Excel's hair, dotted and dashed lines read as thin, medium
and thick ones as thick, double as double, and 012's thick goes out as
medium. Blank cells with borders are written and read, after a row's
last value too. Rows' heights go out as ht in points, 15 to a line, with
customHeight, and only such heights come back. Merged cells are
<mergeCells>.

The excelize reference importer reads the same (merges, borders, wrap,
and heights from the worksheet's XML, as excelize doesn't say which are
custom), and a formula line compared for spaces may carry formatting.
commit
e39d5d9f1b2ef59238bc21bb8da2dcfc5a51cdf0
parent
d85e1478e36026636bbdb06c5c403bf04ab563b4
author
Dami <dami.etoile@gmail.com>
14 files changed, +539 -43
Mdocs/files/excel.md12++
Minternal/fileio/snapshot.go42+++++++
Minternal/fileio/xlsx_diff_test.go3+-
Minternal/fileio/xlsx_excelize_test.go104+++++++++++++++++--
Minternal/fileio/xlsximport.go8+-
Ainternal/fileio/xlsxlayout.go169++++++++++++++++++++++++++++++
Ainternal/fileio/xlsxlayout_test.go118+++++++++++++++++++++
Minternal/fileio/xlsxlines.go23++++-
Minternal/fileio/xlsxsheet.go11+-
Minternal/fileio/xlsxstyles.go36+++++-
Minternal/fileio/xlsxview_test.go2+-
Minternal/fileio/xlsxviewread.go2+
Minternal/fileio/xlsxwrite.go33+++--
Minternal/sheet/rowlayout.go19+++
M docs/files/excel.md +12 -0
@@ -8,6 +8,7 @@ sidebar_position: 2
88 **Read:** every sheet, opening on the one Excel showed; values, formulas99 (references between sheets too, shared formulas), workbook named ranges,1010 number formats, bold, italic, underline, strikethrough, alignment,11+[wrapped text, borders, row heights and merged cells](#layout-in-excel),1112 column widths, column and row styles, frozen panes, [filters](#filters-in-excel),1213 notes (Excel's notes, its legacy comments; threaded comments aren't1314 read), [conditional formatting and data validation](#rules-in-excel),
@@ -34,6 +35,17 @@ What changes on the way:
3435 | A sheet name Excel can't take as is (spaces at its ends, or the same as another's but for them and case) | Written as one it can: without the spaces, with a number when two would clash (`Plan (2)`); formulas and named ranges naming it name that |3536 | Files past the reader's limits (a zip bomb, 1 GB in one part, 2 GB in all, cells past XFD1048576) | Refused, with a message saying which |3637 38+## Layout in Excel39+40+| 012 | Excel |41+|---|---|42+| Wrap | The cell format's `wrapText` |43+| Clip | Nothing: Excel has no clipping, so the text overflows there |44+| Borders: thin, thick, double | Borders in the styles part: `thin`, `medium`, `double`. Coming in, `hair`, `dotted` and the dashed lines read as thin, `medium`, `thick` and the medium dashed lines as thick |45+| A blank cell with borders | Written as a styled cell, and read, even after a row's last value |46+| A row's height, in lines | `ht` in points, 15 to a line, with `customHeight`; coming in, only heights set by hand (`customHeight`) of more than a line, rounded to lines. Rows Excel fitted to their text fit it here too |47+| Merged cells | `mergeCells`; the values Excel keeps under a merge's other cells come in but don't show |48+3749 ## Filters in Excel3850 3951 A sheet's filter goes out as Excel's AutoFilter over the same range, and
M internal/fileio/snapshot.go +42 -0
@@ -48,6 +48,11 @@ type Snapshot struct {
4848 // Notes are the cells' notes in the range, for formats that keep4949 // them (XLSX, as comments). A note may be on a cell with no contents.5050 Notes map[sheet.Addr]string51+52+ // Heights are the rows' heights set by hand, in lines, and Merges53+ // the merged cells in the range, for formats that keep them (XLSX).54+ Heights map[int]int55+ Merges []sheet.Rect5156 }5257 5358 // SnapName is a named range: its name, and the range on the sheet
@@ -91,6 +96,19 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
9196 ColFormats: snapLines(s, false), RowFormats: snapLines(s, true), Filter: s.Filter(),9297 CondFormats: s.CondFormats(), Validations: s.Validations()}9398 snap.FrozenRows, snap.FrozenCols = s.Frozen()99+ for row, h := range s.Heights() {100+ if row >= r.From.Row && row <= r.To.Row {101+ if snap.Heights == nil {102+ snap.Heights = map[int]int{}103+ }104+ snap.Heights[row] = h105+ }106+ }107+ for _, m := range s.Merges() {108+ if r.Contains(m.From) && r.Contains(m.To) {109+ snap.Merges = append(snap.Merges, m)110+ }111+ }94112 for _, a := range s.NotesIn(notes) {95113 if snap.Notes == nil {96114 snap.Notes = map[sheet.Addr]string{}
@@ -129,6 +147,29 @@ func Snap(s *sheet.Sheet, r sheet.Rect, name string) *Snapshot {
129147 return snap130148 }131149 150+// snapLayout adds to a whole sheet's snapshot what its layout needs in151+// a workbook, growing the range to hold it: rows with heights, merged152+// cells, and blank cells that draw borders, which count as contents153+// there.154+func snapLayout(snap *Snapshot, s *sheet.Sheet) {155+ snap.Heights, snap.Merges = s.Heights(), s.Merges()156+ grow := func(x sheet.Rect) {157+ if len(snap.Cells) == 0 && snap.Range.From == snap.Range.To {158+ snap.Range = sheet.Rect{To: x.To}159+ }160+ snap.Range.To.Col, snap.Range.To.Row = max(snap.Range.To.Col, x.To.Col), max(snap.Range.To.Row, x.To.Row)161+ }162+ for _, m := range snap.Merges {163+ grow(m)164+ }165+ for _, a := range s.BorderedIn(sheet.Rect{To: sheet.Addr{Col: sheet.MaxCols - 1, Row: sheet.MaxRows - 1}}) {166+ if _, ok := snap.Cells[a]; !ok {167+ snap.Cells[a] = SnapCell{Style: s.CellStyle(a)}168+ }169+ grow(sheet.Rect{From: a, To: a})170+ }171+}172+132173 // SnapBook copies every sheet of s's workbook, whole, with the named133174 // ranges, for formats that hold several sheets. The result is s's134175 // snapshot.
@@ -137,6 +178,7 @@ func SnapBook(s *sheet.Sheet) *Snapshot {
137178 all := []*Snapshot{}138179 for _, t := range s.Book().Sheets() {139180 sn := Snap(t, sheet.Rect{}, t.Name())181+ snapLayout(sn, t)140182 sn.Hidden = t.Hidden() && t != s141183 if t == s {142184 out = sn
M internal/fileio/xlsx_diff_test.go +2 -1
M internal/fileio/xlsx_excelize_test.go +93 -11
@@ -5,6 +5,7 @@ package fileio
55 66 import (77 "archive/zip"8+ "cmp"89 "context"910 "encoding/xml"1011 "fmt"
@@ -70,10 +71,15 @@ func importXLSXExcelize(ctx context.Context, name string, opt Options) (*Result,
7071 }7172 done += rows7273 }73- filters, protected, err := referenceAutoFilters(name)74+ filters, protected, heights, err := referenceAutoFilters(name)7475 if err != nil {7576 return nil, err7677 }78+ for i, hs := range heights {79+ for row, h := range hs {80+ book.Sheet(i).LoadRowHeight(row, h)81+ }82+ }7783 notes = append(notes, excelizeNames(x, book)...)7884 notes = append(notes, (&xlsxBook{protected: protected}).protectionNote()...)7985 active := book.Sheet(clamp(x.GetActiveSheetIndex(), 0, book.Len()-1))
@@ -117,6 +123,11 @@ func excelizeNotes(x *excelize.File, ws string, s *sheet.Sheet) error {
117123 118124 // refWorksheet is the part of a worksheet referenceAutoFilters reads.119125 type refWorksheet struct {126+ Rows []struct {127+ R int `xml:"r,attr"`128+ Ht string `xml:"ht,attr"`129+ Custom string `xml:"customHeight,attr"`130+ } `xml:"sheetData>row"`120131 Protection *struct {121132 Sheet string `xml:"sheet,attr"`122133 } `xml:"sheetProtection"`
@@ -145,30 +156,32 @@ type refWorksheet struct {
145156 } `xml:"autoFilter"`146157 }147158 148-// referenceAutoFilters reads each sheet's autoFilter, and the names of149-// the protected sheets, by decoding the whole worksheet with159+// referenceAutoFilters reads each sheet's autoFilter, the names of the160+// protected sheets and the heights of rows set by hand (excelize doesn't161+// say which those are), by decoding the whole worksheet with150162 // encoding/xml, as excelize has no API for reading them: the reference151163 // for the streaming reader's. Only the parts' names come from 012's152164 // reader.153-func referenceAutoFilters(name string) ([]*xlsxAutoFilter, []string, error) {165+func referenceAutoFilters(name string) ([]*xlsxAutoFilter, []string, []map[int]int, error) {154166 f, err := os.Open(name)155167 if err != nil {156- return nil, nil, err168+ return nil, nil, nil, err157169 }158170 defer f.Close()159171 st, err := f.Stat()160172 if err != nil {161- return nil, nil, err173+ return nil, nil, nil, err162174 }163175 bk, err := openXLSX(f, st.Size(), defaultXLSXLimits)164176 if err != nil {165- return nil, nil, err177+ return nil, nil, nil, err166178 }167179 zr, err := zip.NewReader(f, st.Size())168180 if err != nil {169- return nil, nil, err181+ return nil, nil, nil, err170182 }171183 out := make([]*xlsxAutoFilter, len(bk.sheets))184+ heights := make([]map[int]int, len(bk.sheets))172185 var protected []string173186 for i, info := range bk.sheets {174187 if info.part == "" || info.kind != "worksheet" {
@@ -176,20 +189,36 @@ func referenceAutoFilters(name string) ([]*xlsxAutoFilter, []string, error) {
176189 }177190 rc, err := zr.Open(info.part)178191 if err != nil {179- return nil, nil, err192+ return nil, nil, nil, err180193 }181194 var ws refWorksheet182195 err = xml.NewDecoder(rc).Decode(&ws)183196 rc.Close()184197 if err != nil {185- return nil, nil, err198+ return nil, nil, nil, err186199 }187200 out[i] = ws.autoFilter()188201 if p := ws.Protection; p != nil && (p.Sheet == "1" || p.Sheet == "true") {189202 protected = append(protected, info.name)190203 }204+ heights[i] = ws.heights()205+ }206+ return out, protected, heights, nil207+}208+209+// heights are the rows' heights set by hand, in lines, by row from 0.210+func (ws refWorksheet) heights() map[int]int {211+ out := map[int]int{}212+ for _, r := range ws.Rows {213+ se := xml.StartElement{Attr: []xml.Attr{{Name: xml.Name{Local: "ht"}, Value: r.Ht}, {Name: xml.Name{Local: "customHeight"}, Value: r.Custom}}}214+ if r.Custom == "" {215+ se.Attr = se.Attr[:1]216+ }217+ if h := rowHeight(se); h > 0 && r.R >= 1 {218+ out[r.R-1] = h219+ }191220 }192- return out, protected, nil221+ return out193222 }194223 195224 func (ws refWorksheet) autoFilter() *xlsxAutoFilter {
@@ -236,6 +265,11 @@ func excelizeSheet(ctx context.Context, x *excelize.File, b *builder, ws string,
236265 return 0, err237266 }238267 defer rows.Close()268+ dimCols := 0269+ if dim, err := x.GetSheetDimension(ws); err == nil {270+ _, to, _ := strings.Cut(dim, ":")271+ dimCols, _, _ = excelize.CellNameToCoordinates(cmp.Or(to, dim))272+ }239273 row, width := 0, 0240274 for ; rows.Next(); row++ {241275 if row%128 == 0 {
@@ -260,6 +294,7 @@ func excelizeSheet(ctx context.Context, x *excelize.File, b *builder, ws string,
260294 cell, _ := excelize.CoordinatesToCellName(col+1, row+1)261295 excelizeCell(x, b, ws, cell, a, raw, styles)262296 }297+ excelizeBordered(x, b, ws, row, len(cols), dimCols, styles)263298 }264299 if err := rows.Error(); err != nil {265300 return 0, err
@@ -280,6 +315,13 @@ func excelizeSheet(ctx context.Context, x *excelize.File, b *builder, ws string,
280315 }281316 b.s.LoadColWidth(c, max(int(math.Round(w))+excelPadding, 1))282317 }318+ if merges, err := x.GetMergeCells(ws, true); err == nil {319+ for _, mc := range merges {320+ if r, ok := sheet.ParseRange(mc.GetStartAxis() + ":" + mc.GetEndAxis()); ok {321+ b.s.LoadMerge(r)322+ }323+ }324+ }283325 return row, excelizeView(x, ws, b.s)284326 }285327
@@ -310,6 +352,10 @@ func excelizeNames(x *excelize.File, book *sheet.Workbook) []string {
310352 return nil311353 }312354 355+// excelizeBorderStyles are Excel's border styles by excelize's index.356+var excelizeBorderStyles = []string{"none", "thin", "medium", "dashed", "dotted", "thick", "double", "hair",357+ "mediumDashed", "dashDot", "mediumDashDot", "dashDotDot", "mediumDashDotDot", "slantDashDot"}358+313359 func excelizeStyleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyle {314360 if st, ok := cache[id]; ok {315361 return st
@@ -325,7 +371,26 @@ func excelizeStyleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyl
325371 out.style.Bold, out.style.Italic, out.style.Strikethrough = f.Bold, f.Italic, f.Strike326372 out.style.Underline = f.Underline != "" && f.Underline != "none"327373 }374+ for _, br := range st.Border {375+ l := sheet.LineNone376+ if br.Style > 0 && br.Style < len(excelizeBorderStyles) {377+ l = borderLine(excelizeBorderStyles[br.Style])378+ }379+ switch br.Type {380+ case "left":381+ out.style.Borders.Left = l382+ case "right":383+ out.style.Borders.Right = l384+ case "top":385+ out.style.Borders.Top = l386+ case "bottom":387+ out.style.Borders.Bottom = l388+ }389+ }328390 if al := st.Alignment; al != nil {391+ if al.WrapText {392+ out.style.Wrap = sheet.WrapOn393+ }329394 switch al.Horizontal {330395 case "left":331396 out.style.Align = sheet.AlignLeft
@@ -340,6 +405,23 @@ func excelizeStyleOf(x *excelize.File, id int, cache map[int]xlsxStyle) xlsxStyl
340405 return out341406 }342407 408+// excelizeBordered stores the blank cells of row from column from (from409+// 0) up to the last one within dimCols that draws borders, which 012's410+// reader keeps though no value follows them.411+func excelizeBordered(x *excelize.File, b *builder, ws string, row, from, dimCols int, styles map[int]xlsxStyle) {412+ last := -1413+ for col := from; col < min(dimCols, sheet.MaxCols); col++ {414+ cell, _ := excelize.CoordinatesToCellName(col+1, row+1)415+ if id, err := x.GetCellStyle(ws, cell); err == nil && id != 0 && !excelizeStyleOf(x, id, styles).style.Borders.IsZero() {416+ last = col417+ }418+ }419+ for col := from; col <= last; col++ {420+ cell, _ := excelize.CoordinatesToCellName(col+1, row+1)421+ excelizeCell(x, b, ws, cell, sheet.Addr{Col: col, Row: row}, "", styles)422+ }423+}424+343425 func excelizeCell(x *excelize.File, b *builder, ws, cell string, a sheet.Addr, raw string, styles map[int]xlsxStyle) {344426 var st xlsxStyle345427 if id, err := x.GetCellStyle(ws, cell); err == nil && id != 0 {
M internal/fileio/xlsximport.go +7 -1
A internal/fileio/xlsxlayout.go +169 -0
@@ -0,0 +1,169 @@
1+package fileio2+3+import (4+ "bufio"5+ "encoding/xml"6+ "fmt"7+ "math"8+ "slices"9+ "strings"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// Wrapping, borders, row heights and merged cells in XLSX, both ways.15+// Wrapped text is a cell format's alignment wrapText; clipping has no16+// XLSX form, so clipped text overflows in Excel. Borders are the styles17+// part's <borders>, a cell format pointing at one: Excel's hair, thin,18+// dotted and dashed lines read as thin, medium and thick ones as thick,19+// double as double, and 012's thick is written as medium, the weight20+// Sheets' middle line has. A row's height is in points, 15 to a line,21+// written with customHeight as a height set by hand; only those are22+// read, as Excel sizes the others to fit. Merged cells are <mergeCells>23+// after the cells.24+25+// pointsPerLine is the height of a line of text in points: Excel's26+// default row height.27+const pointsPerLine = 1528+29+// borderLine maps an Excel border style to a line.30+func borderLine(style string) sheet.Line {31+ switch style {32+ case "", "none":33+ return sheet.LineNone34+ case "double":35+ return sheet.LineDouble36+ case "medium", "thick", "mediumDashed", "mediumDashDot", "mediumDashDotDot", "slantDashDot":37+ return sheet.LineThick38+ }39+ return sheet.LineThin40+}41+42+// excelBorderStyle is the Excel border style that draws l.43+func excelBorderStyle(l sheet.Line) string {44+ switch l {45+ case sheet.LineThick:46+ return "medium"47+ case sheet.LineDouble:48+ return "double"49+ }50+ return "thin"51+}52+53+// readBorder reads the rest of a <border>: its four edges.54+func readBorder(x *xmlStream) (sheet.Borders, error) {55+ var b sheet.Borders56+ depth := x.depth57+ for {58+ t, err := x.next()59+ if err != nil {60+ return b, eofAsUnexpected(err)61+ }62+ switch t := t.(type) {63+ case xml.StartElement:64+ if x.depth != depth+1 {65+ continue66+ }67+ l := borderLine(attrOr(t, "style", ""))68+ switch t.Name.Local {69+ case "top":70+ b.Top = l71+ case "bottom":72+ b.Bottom = l73+ case "left", "start":74+ b.Left = l75+ case "right", "end":76+ b.Right = l77+ }78+ case xml.EndElement:79+ if x.depth < depth {80+ return b, nil81+ }82+ }83+ }84+}85+86+// borderID is the index of the <border> drawing b, 0 for none.87+func (t *xlsxStyleTable) borderID(b sheet.Borders) int {88+ if i := slices.Index(t.borders, b); i >= 0 {89+ return i90+ }91+ t.borders = append(t.borders, b)92+ return len(t.borders) - 193+}94+95+// bordersXML writes the <borders>, the first drawing none.96+func (t *xlsxStyleTable) bordersXML() string {97+ var b strings.Builder98+ fmt.Fprintf(&b, `<borders count="%d">`, len(t.borders))99+ for _, br := range t.borders {100+ b.WriteString(`<border>`)101+ for _, e := range [...]struct {102+ tag string103+ l sheet.Line104+ }{{"left", br.Left}, {"right", br.Right}, {"top", br.Top}, {"bottom", br.Bottom}} {105+ if e.l == sheet.LineNone {106+ fmt.Fprintf(&b, `<%s/>`, e.tag)107+ continue108+ }109+ fmt.Fprintf(&b, `<%s style="%s"><color auto="1"/></%s>`, e.tag, excelBorderStyle(e.l), e.tag)110+ }111+ b.WriteString(`<diagonal/></border>`)112+ }113+ return b.String() + `</borders>`114+}115+116+// alignmentXML is a cell format's <alignment>, or "" when it has none.117+func alignmentXML(st sheet.Style) string {118+ if st.Align == sheet.AlignAuto && st.Wrap != sheet.WrapOn {119+ return ""120+ }121+ s := `<alignment`122+ if st.Align != sheet.AlignAuto {123+ s += fmt.Sprintf(` horizontal="%s"`, st.Align)124+ }125+ if st.Wrap == sheet.WrapOn {126+ s += ` wrapText="1"`127+ }128+ return s + `/>`129+}130+131+// rowHeight is the height in lines of a <row> set by hand to more than132+// a line, and 0 for one that fits its contents.133+func rowHeight(se xml.StartElement) int {134+ if !boolAttr(se, "customHeight", false) {135+ return 0136+ }137+ var ht float64138+ if _, err := fmt.Sscan(attrOr(se, "ht", ""), &ht); err != nil || !(ht > 0) {139+ return 0140+ }141+ if lines := clamp(int(math.Round(ht/pointsPerLine)), 1, sheet.MaxRowHeight); lines > 1 {142+ return lines143+ }144+ return 0145+}146+147+// maxMergesRead caps the merged ranges read from a worksheet.148+const maxMergesRead = 1 << 16149+150+// writeMerges writes the sheet's <mergeCells>.151+func writeMerges(bw *bufio.Writer, snap *Snapshot) {152+ if len(snap.Merges) == 0 {153+ return154+ }155+ fmt.Fprintf(bw, `<mergeCells count="%d">`, len(snap.Merges))156+ for _, m := range snap.Merges {157+ fmt.Fprintf(bw, `<mergeCell ref="%s"/>`, excelRect(m))158+ }159+ bw.WriteString(`</mergeCells>`)160+}161+162+// readMerge reads a <mergeCell>'s range, ignoring one that isn't a163+// range of the sheet.164+func (r *xlsxSheetReader) readMerge(se xml.StartElement) {165+ ref, _ := attr(se, "ref")166+ if m, ok := sheet.ParseRange(strings.ReplaceAll(ref, "$", "")); ok && len(r.merges) < maxMergesRead {167+ r.merges = append(r.merges, m)168+ }169+}
A internal/fileio/xlsxlayout_test.go +118 -0
@@ -0,0 +1,118 @@
1+package fileio2+3+import (4+ "context"5+ "path/filepath"6+ "slices"7+ "testing"8+9+ "github.com/xuri/excelize/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+func rangeOf(t testing.TB, s string) sheet.Rect {15+ t.Helper()16+ r, ok := sheet.ParseRange(s)17+ if !ok {18+ t.Fatalf("bad range %q", s)19+ }20+ return r21+}22+23+// Wrapping, borders, heights and merges go out to Excel and come back.24+func TestXLSXLayoutRoundTrip(t *testing.T) {25+ src := build(t, map[string]string{"A1": "Title", "A2": "a long text that wraps", "B2": "2"})26+ src.Merge(rangeOf(t, "A1:C1"), sheet.MergeAll)27+ src.SetStyle(rangeOf(t, "A2"), func(st *sheet.Style) { st.Wrap = sheet.WrapOn })28+ src.SetBorders(rangeOf(t, "A2:C3"), sheet.BorderAll, sheet.LineThin)29+ src.SetBorders(rangeOf(t, "A2:C3"), sheet.BorderOuter, sheet.LineThick)30+ src.SetBorders(rangeOf(t, "B2"), sheet.BorderBottom, sheet.LineDouble)31+ src.SetRowHeight(4, 4, 3)32+33+ name := filepath.Join(t.TempDir(), "layout.xlsx")34+ if _, err := Export(context.Background(), name, XLSX, SnapBook(src), ExportOptions{}); err != nil {35+ t.Fatal(err)36+ }37+ x, err := excelize.OpenFile(name)38+ if err != nil {39+ t.Fatal(err)40+ }41+ ws := x.GetSheetName(0)42+ if mc, _ := x.GetMergeCells(ws); len(mc) != 1 || mc[0].GetStartAxis() != "A1" || mc[0].GetEndAxis() != "C1" {43+ t.Errorf("merges in Excel: %v", mc)44+ }45+ if h, _ := x.GetRowHeight(ws, 5); h != 45 {46+ t.Errorf("row 5 is %v points", h)47+ }48+ id, _ := x.GetCellStyle(ws, "C3") // a blank cell drawing lines49+ st, _ := x.GetStyle(id)50+ if st == nil || len(st.Border) == 0 {51+ t.Errorf("C3's borders didn't go to Excel: %+v", st)52+ }53+ id, _ = x.GetCellStyle(ws, "A2")54+ if st, _ = x.GetStyle(id); st == nil || st.Alignment == nil || !st.Alignment.WrapText {55+ t.Errorf("A2 doesn't wrap in Excel: %+v", st)56+ }57+ x.Close()58+59+ res, err := Import(context.Background(), name, Options{})60+ if err != nil {61+ t.Fatal(err)62+ }63+ got := res.Sheet64+ if !slices.Equal(got.Merges(), src.Merges()) {65+ t.Errorf("merges %v", got.Merges())66+ }67+ if h, _ := got.RowHeight(4); h != 3 {68+ t.Errorf("row 5 height %d", h)69+ }70+ for _, a := range []string{"A2", "B2", "C2", "A3", "B3", "C3", "B4"} {71+ if g, w := got.CellStyle(addr(t, a)), src.CellStyle(addr(t, a)); g != w {72+ t.Errorf("%s style %+v, want %+v", a, g, w)73+ }74+ }75+}76+77+// What Excel writes: its other line styles, heights it fitted itself78+// (not read) and ones set by hand, and merged cells.79+func TestXLSXLayoutImport(t *testing.T) {80+ p := oneSheetParts("", `<row r="1" ht="45" customHeight="1"><c r="A1" s="1"><v>1</v></c><c r="B1" s="2"/></row>`+81+ `<row r="2" ht="60"><c r="A2" s="3" t="inlineStr"><is><t>fits</t></is></c></row>`+82+ `<row r="3" ht="15.75" customHeight="1"><c r="A3" s="1"/><c r="D3"><v>4</v></c></row>`)83+ p["xl/styles.xml"] = `<styleSheet ` + mainNS + `><fonts count="1"><font/></fonts>` +84+ `<borders count="3"><border><left/><right/><top/><bottom/></border>` +85+ `<border><left style="hair"/><right style="mediumDashed"/><top style="double"/><bottom style="thick"/></border>` +86+ `<border><left style="dashDot"/></border></borders>` +87+ `<cellXfs count="4"><xf numFmtId="0" fontId="0" borderId="0"/><xf numFmtId="0" fontId="0" borderId="1" applyBorder="1"/>` +88+ `<xf numFmtId="0" fontId="0" borderId="2"/><xf numFmtId="0" fontId="0" borderId="0"><alignment wrapText="1"/></xf></cellXfs></styleSheet>`89+ p["xl/worksheets/sheet1.xml"] = p["xl/worksheets/sheet1.xml"][:len(p["xl/worksheets/sheet1.xml"])-len(`</worksheet>`)] +90+ `<mergeCells count="2"><mergeCell ref="B2:C3"/><mergeCell ref="$E$5:$E$9"/></mergeCells></worksheet>`91+ name := writeParts(t, t.TempDir(), "excel.xlsx", p)92+ res, err := Import(context.Background(), name, Options{})93+ if err != nil {94+ t.Fatal(err)95+ }96+ s := res.Sheet97+ want := sheet.Borders{Left: sheet.LineThin, Right: sheet.LineThick, Top: sheet.LineDouble, Bottom: sheet.LineThick}98+ if got := s.CellStyle(addr(t, "A1")).Borders; got != want {99+ t.Errorf("A1 borders %+v, want %+v", got, want)100+ }101+ if got := s.CellStyle(addr(t, "B1")).Borders.Left; got != sheet.LineThin {102+ t.Errorf("a blank cell's border after the row's values: %v", got)103+ }104+ if s.CellStyle(addr(t, "A2")).Wrap != sheet.WrapOn {105+ t.Error("wrapText not read")106+ }107+ if h, ok := s.RowHeight(0); !ok || h != 3 {108+ t.Errorf("row 1 height %d, %v", h, ok)109+ }110+ for _, row := range []int{1, 2} {111+ if _, ok := s.RowHeight(row); ok {112+ t.Errorf("row %d has a height", row+1)113+ }114+ }115+ if got := s.Merges(); len(got) != 2 || got[1] != rangeOf(t, "E5:E9") {116+ t.Errorf("merges %v", got)117+ }118+}
M internal/fileio/xlsxlines.go +20 -3
@@ -116,20 +116,37 @@ func (w *xlsxWriter) writeCols(bw *bufio.Writer, snap *Snapshot) {
116116 bw.WriteString(`</cols>`)117117 }118118 119-// rowStart writes a <row> start tag, with the row's style if it has one.119+// lineRows are the rows with a style or a height of their own, in120+// order.121+func lineRows(snap *Snapshot) []int {122+ rows := slices.Collect(maps.Keys(snap.RowFormats))123+ for r := range snap.Heights {124+ if _, styled := snap.RowFormats[r]; !styled {125+ rows = append(rows, r)126+ }127+ }128+ slices.Sort(rows)129+ return rows130+}131+132+// rowStart writes a <row> start tag, with the row's style and height if133+// it has them.120134 func (w *xlsxWriter) rowStart(bw *bufio.Writer, snap *Snapshot, row int) {121135 fmt.Fprintf(bw, `<row r="%d"`, row+1)122136 if l, ok := snap.RowFormats[row]; ok {123137 fmt.Fprintf(bw, ` s="%d" customFormat="1"`, w.styles.id(l.Format, l.Style))124138 }139+ if h, ok := snap.Heights[row]; ok {140+ fmt.Fprintf(bw, ` ht="%d" customHeight="1"`, h*pointsPerLine)141+ }125142 if snap.HiddenRows[row] {126143 bw.WriteString(` hidden="1"`)127144 }128145 bw.WriteString(`>`)129146 }130147 131-// styledRows writes, as empty rows, the styled rows (ascending) before132-// row to, returning those left.148+// styledRows writes, as empty rows, the rows of lineRows (ascending)149+// before row to, returning those left.133150 func (w *xlsxWriter) styledRows(bw *bufio.Writer, snap *Snapshot, rows []int, to int) []int {134151 for len(rows) > 0 && rows[0] < to {135152 w.rowStart(bw, snap, rows[0])
M internal/fileio/xlsxsheet.go +7 -4
M internal/fileio/xlsxstyles.go +28 -8
@@ -11,11 +11,12 @@ import (
1111 )1212 1313 // xlsxStyles is what 012 reads of the styles part: custom number14-// formats, fonts, and the cell formats (cellXfs) that cells refer to by15-// index, resolved to 012 formats and styles on first use.14+// formats, fonts, borders, and the cell formats (cellXfs) that cells15+// refer to by index, resolved to 012 formats and styles on first use.1616 type xlsxStyles struct {1717 numFmts map[int]string1818 fonts []xlsxFont19+ borders []sheet.Borders1920 xfs []xlsxXf2021 cache map[int]xlsxStyle2122 dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrulesread.go
@@ -23,11 +24,13 @@ type xlsxStyles struct {
2324 2425 type xlsxFont struct{ bold, italic, strike, underline bool }2526 26-// xlsxXf is a cell format; numFmt and font are -1 when not given.27+// xlsxXf is a cell format; numFmt, font and border are -1 when not28+// given.2729 type xlsxXf struct {28- numFmt, font int29- applyFont, applyAlign bool30- align sheet.Align30+ numFmt, font, border int31+ applyFont, applyAlign, applyBorder bool32+ align sheet.Align33+ wrap bool3134 }3235 3336 // style is the cell format with index id, as 012 keeps it; an unknown
@@ -48,6 +51,12 @@ func (s *xlsxStyles) style(id int) xlsxStyle {
4851 }4952 if xf.applyAlign {5053 out.style.Align = xf.align54+ if xf.wrap {55+ out.style.Wrap = sheet.WrapOn56+ }57+ }58+ if xf.applyBorder && xf.border >= 0 && xf.border < len(s.borders) {59+ out.style.Borders = s.borders[xf.border]5160 }5261 if s.cache == nil {5362 s.cache = map[int]xlsxStyle{}
@@ -120,7 +129,7 @@ func (s *xlsxStyles) read(p *xlsxPackage, part string) error {
120129 // element reads one element inside a section of the styles part.121130 func (s *xlsxStyles) element(x *xmlStream, se xml.StartElement, section string, cur *xlsxXf) (*xlsxXf, error) {122131 full := len(s.numFmts) >= x.g.p.lim.styles || len(s.fonts) >= x.g.p.lim.styles || len(s.xfs) >= x.g.p.lim.styles ||123- len(s.dxfs) >= x.g.p.lim.styles132+ len(s.dxfs) >= x.g.p.lim.styles || len(s.borders) >= x.g.p.lim.styles124133 switch {125134 case section == "dxfs" && se.Name.Local == "dxf" && x.depth == 3:126135 if full {
@@ -148,16 +157,27 @@ func (s *xlsxStyles) element(x *xmlStream, se xml.StartElement, section string,
148157 return nil, err149158 }150159 s.fonts = append(s.fonts, f)160+ case section == "borders" && se.Name.Local == "border" && x.depth == 3:161+ if full {162+ return nil, fmt.Errorf("the workbook has more than %d borders: %w", x.g.p.lim.styles, errXLSXLimit)163+ }164+ b, err := readBorder(x)165+ if err != nil {166+ return nil, err167+ }168+ s.borders = append(s.borders, b)151169 case section == "cellXfs" && se.Name.Local == "xf" && x.depth == 3:152170 if full {153171 return nil, fmt.Errorf("the workbook has more than %d cell formats: %w", x.g.p.lim.styles, errXLSXLimit)154172 }155173 s.xfs = append(s.xfs, xlsxXf{156- numFmt: intAttr(se, "numFmtId", -1), font: intAttr(se, "fontId", -1),174+ numFmt: intAttr(se, "numFmtId", -1), font: intAttr(se, "fontId", -1), border: intAttr(se, "borderId", -1),157175 applyFont: boolAttr(se, "applyFont", true), applyAlign: boolAttr(se, "applyAlignment", true),176+ applyBorder: boolAttr(se, "applyBorder", true),158177 })159178 return &s.xfs[len(s.xfs)-1], nil160179 case section == "cellXfs" && se.Name.Local == "alignment" && x.depth == 4 && cur != nil:180+ cur.wrap = boolAttr(se, "wrapText", false)161181 switch h, _ := attr(se, "horizontal"); h {162182 case "left":163183 cur.align = sheet.AlignLeft
M internal/fileio/xlsxview_test.go +1 -1
M internal/fileio/xlsxviewread.go +2 -0
M internal/fileio/xlsxwrite.go +19 -14
@@ -3,7 +3,6 @@ package fileio
33 import (44 "bufio"55 "fmt"6- "maps"76 "math"87 "slices"98 "strconv"
@@ -78,10 +77,10 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
7877 bw.WriteString(`<sheetFormatPr defaultRowHeight="15"/>`)7978 w.writeCols(bw, snap)8079 bw.WriteString(`<sheetData>`)81- styled := slices.Sorted(maps.Keys(snap.RowFormats)) // rows with a style of their own, written even without cells80+ styled := lineRows(snap) // rows with a style or height of their own, written even without cells8281 for row := r.From.Row; row <= r.To.Row; row++ {8382 styled = w.styledRows(bw, snap, styled, row)84- _, rowStyled := snap.RowFormats[row]83+ rowStyled := len(styled) > 0 && styled[0] == row8584 if rowStyled {8685 styled = styled[1:]8786 }
@@ -102,6 +101,7 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
102101 w.styledRows(bw, snap, styled, sheet.MaxRows)103102 bw.WriteString(`</sheetData>`)104103 writeAutoFilter(bw, snap)104+ writeMerges(bw, snap)105105 w.writeRules(bw, ws, snap)106106 if len(snap.Notes) > 0 {107107 bw.WriteString(`<legacyDrawing r:id="` + vmlRelID + `"/>`)
@@ -285,11 +285,12 @@ func xmlChar(r rune) bool {
285285 // distinct number format and text style is a cellXfs entry, with its286286 // number format code and font.287287 type xlsxStyleTable struct {288- ids map[xlsxStyle]int289- xfs []xlsxStyle // index 0 is the default290- codes []string // custom number formats, from id 164291- fonts []sheet.Style292- dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrules.go288+ ids map[xlsxStyle]int289+ xfs []xlsxStyle // index 0 is the default290+ codes []string // custom number formats, from id 164291+ fonts []sheet.Style292+ borders []sheet.Borders // see xlsxlayout.go; index 0 is none293+ dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrules.go293294 }294295 295296 // dxf is the index of the differential style for a rule's style.
@@ -335,7 +336,7 @@ func (t *xlsxStyleTable) dxfsXML() string {
335336 }336337 337338 func newXLSXStyleTable() *xlsxStyleTable {338- return &xlsxStyleTable{ids: map[xlsxStyle]int{{}: 0}, xfs: []xlsxStyle{{}}, fonts: []sheet.Style{{}}}339+ return &xlsxStyleTable{ids: map[xlsxStyle]int{{}: 0}, xfs: []xlsxStyle{{}}, fonts: []sheet.Style{{}}, borders: []sheet.Borders{{}}}339340 }340341 341342 // id is the index of the cell format for f and st.
@@ -369,18 +370,23 @@ func (t *xlsxStyleTable) xml() string {
369370 i = len(t.fonts)370371 t.fonts = append(t.fonts, font)371372 }372- fmt.Fprintf(&xfs, `<xf numFmtId="%d" fontId="%d" fillId="0" borderId="0" xfId="0"`, numFmt, i)373+ border := t.borderID(x.style.Borders)374+ fmt.Fprintf(&xfs, `<xf numFmtId="%d" fontId="%d" fillId="0" borderId="%d" xfId="0"`, numFmt, i, border)373375 if numFmt != 0 {374376 xfs.WriteString(` applyNumberFormat="1"`)375377 }376378 if i != 0 {377379 xfs.WriteString(` applyFont="1"`)378380 }379- if x.style.Align == sheet.AlignAuto {381+ if border != 0 {382+ xfs.WriteString(` applyBorder="1"`)383+ }384+ align := alignmentXML(x.style)385+ if align == "" {380386 xfs.WriteString(`/>`)381387 continue382388 }383- fmt.Fprintf(&xfs, ` applyAlignment="1"><alignment horizontal="%s"/></xf>`, x.style.Align)389+ xfs.WriteString(` applyAlignment="1">` + align + `</xf>`)384390 }385391 var b strings.Builder386392 b.WriteString(xmlHead + `<styleSheet xmlns="` + sheetMain + `">`)
@@ -405,8 +411,7 @@ func (t *xlsxStyleTable) xml() string {
405411 b.WriteString(`<sz val="11"/><name val="Calibri"/><family val="2"/></font>`)406412 }407413 b.WriteString(`</fonts><fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>` +408- `<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +409- `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`)414+ t.bordersXML() + `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`)410415 fmt.Fprintf(&b, `<cellXfs count="%d">%s</cellXfs>`, len(t.xfs), xfs.String())411416 b.WriteString(`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>` + t.dxfsXML() + `</styleSheet>`)412417 return b.String()
M internal/sheet/rowlayout.go +19 -0