A cell's style holds a vertical alignment (automatic, top, middle, bottom) and a color for each border line from the named colors rules use; Style grows to twelve bytes and moves to the end of Cell, where it fits in the padding after the flags, so a Cell stays 256 bytes (TestCellSize). Both are saved as optional fields (valign, and topColor... in borders), read by the streaming reader (FuzzRead seeds, valid and not), and go to XLSX as the alignment's vertical and the border's color, colors coming in as the nearest named one. The outline of whole columns or rows draws the sheet's first and last edges, as lines where it covers the whole sheet. MergeAcross finds a merge a freeze would cut.
- commit
- 4793f2b82616d16f0ef02ef0153cf154aedefd66
- parent
- b820f463a8579ec24cdbb6f4c4894538f6e0e32c
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 2.2 of 3, Jev's estimate
| M | docs/ | 2 | + |
| M | docs/ | 9 | +- |
| M | internal/ | 54 | ++++++++++--- |
| M | internal/ | 20 | ++++- |
| M | internal/ | 4 | +- |
| M | internal/ | 128 | +++++++++++++++++++++++++----- |
| M | internal/ | 44 | ++++++++-- |
| M | internal/ | 4 | + |
| A | internal/ | 95 | ++++++++++++++++++++++ |
| M | internal/ | 16 | ++++ |
| M | internal/ | 10 | +- |
| M | internal/ | 53 | ++++++++++++- |
@@ -43,6 +43,8 @@ What changes on the way:
4343 | Wrap | The cell format's `wrapText` |4444 | Clip | Nothing: Excel has no clipping, so the text overflows there |4545 | 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 |46+| Border colors | The line's `color`, as the RGB the rules' colors write; coming in, the nearest named color by hue, and grays and black as Automatic |47+| Vertical alignment: top, middle, bottom | The cell format's `vertical`: `top`, `center`, `bottom`; `justify` and `distributed` come in as automatic |4648 | A blank cell with borders | Written as a styled cell, and read, even after a row's last value |4749 | 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 |4850 | Merged cells | `mergeCells`; the values Excel keeps under a merge's other cells come in but don't show |@@ -27,9 +27,11 @@ A formatted cell has its `input` and the formatting that isn't the
2727 default: `format` (as `number_format` in2828 [macros](../reference/macro-api.md#cells) names it: `currency`, `percent`, `date`, ...), `decimals`, `pattern` for a2929 custom format, `bold`, `italic`, `underline`, `strikethrough`,30-`align` (`left`, `center`, `right`), `wrap` (`wrap` or `clip`) and31-`borders`, each edge's line by name:32-`"borders":{"top":"thin","bottom":"double","left":"thick","right":"thin"}`30+`align` (`left`, `center`, `right`), `valign` (`top`, `middle`,31+`bottom`), `wrap` (`wrap` or `clip`) and `borders`, each edge's line by32+name, with `topColor`, `bottomColor`, `leftColor` and `rightColor` for33+a line in a [color](../sheets/formatting.md#border-colors):34+`"borders":{"top":"thin","bottom":"double","left":"thick","right":"thin","bottomColor":"red"}`3335 ([formatting](../sheets/formatting.md#borders)).3436 3537 Only what's typed is saved. What 012 computes is computed again when the@@ -84,6 +86,7 @@ save), so they raise no version:
8486 | `note` | a cell | Its [note](../sheets/notes-protection.md#notes) |8587 | `own` | a cell | `true` when its formatting is its own, not its column's or row's (Automatic in a currency column) |8688 | `wrap`, `borders` | a cell or a line | How its text [wraps](../sheets/formatting.md#wrapping) and its [borders](../sheets/formatting.md#borders); older builds show the text overflowing, without lines |89+| `valign`, and colors in `borders` | a cell or a line | Where its text sits in a [tall row](../sheets/formatting.md#vertical-alignment), and its borders' colors; older builds show the text at the bottom and the lines in the text's ink |8790 | `lines` | a sheet | [Column and row formats](#column-and-row-formats) |8891 | `heights` | a sheet | [Row heights](../sheets/formatting.md#row-heights) set by hand, in lines, by row number: `"heights": {"3": 2, "7": 4}` |8992 | `merges` | a sheet | [Merged cells](../sheets/formatting.md#merged-cells), by range: `"merges": ["A1:C1", "D2:D5"]` |@@ -56,10 +56,26 @@ var xlsxEdges = map[string]sheet.Edge{
5656 "top": sheet.EdgeTop, "bottom": sheet.EdgeBottom, "left": sheet.EdgeLeft, "start": sheet.EdgeLeft, "right": sheet.EdgeRight, "end": sheet.EdgeRight,5757 }5858 59-// readBorder reads the rest of a <border>: its four edges.59+// excelVAligns are Excel's vertical alignments; justify and distributed60+// have no match and sit where 012's text sits by default.61+var excelVAligns = map[string]sheet.VAlign{"top": sheet.VAlignTop, "center": sheet.VAlignMiddle, "bottom": sheet.VAlignBottom}62+63+// excelVAlign is the vertical alignment Excel writes for v.64+func excelVAlign(v sheet.VAlign) string {65+ for name, va := range excelVAligns {66+ if va == v {67+ return name68+ }69+ }70+ return ""71+}72+73+// readBorder reads the rest of a <border>: its four edges, each with74+// its line's color as the nearest named color (see xlsxColor.named).6075 func readBorder(x *xmlStream) (sheet.Borders, error) {6176 var b sheet.Borders6277 depth := x.depth78+ edge, inEdge := sheet.EdgeTop, false6379 for {6480 t, err := x.next()6581 if err != nil {@@ -67,11 +83,14 @@ func readBorder(x *xmlStream) (sheet.Borders, error) {
6783 }6884 switch t := t.(type) {6985 case xml.StartElement:70- if x.depth != depth+1 {71- continue72- }73- if e, ok := xlsxEdges[t.Name.Local]; ok {74- b = b.With(e, borderLine(attrOr(t, "style", "")))86+ switch {87+ case x.depth == depth+1:88+ edge, inEdge = xlsxEdges[t.Name.Local]89+ if inEdge {90+ b = b.With(edge, borderLine(attrOr(t, "style", "")))91+ }92+ case x.depth == depth+2 && inEdge && t.Name.Local == "color" && b.Line(edge) != sheet.LineNone:93+ b = b.WithColor(edge, readColor(t).named())7594 }7695 case xml.EndElement:7796 if x.depth < depth {@@ -97,29 +116,42 @@ func (t *xlsxStyleTable) bordersXML() string {
97116 for _, br := range t.borders {98117 b.WriteString(`<border>`)99118 for _, e := range [...]struct {100- tag string101- l sheet.Line102- }{{"left", br.Left()}, {"right", br.Right()}, {"top", br.Top()}, {"bottom", br.Bottom()}} {119+ tag string120+ l sheet.Line121+ edge sheet.Edge122+ }{{"left", br.Left(), sheet.EdgeLeft}, {"right", br.Right(), sheet.EdgeRight}, {"top", br.Top(), sheet.EdgeTop}, {"bottom", br.Bottom(), sheet.EdgeBottom}} {103123 if e.l == sheet.LineNone {104124 fmt.Fprintf(&b, `<%s/>`, e.tag)105125 continue106126 }107- fmt.Fprintf(&b, `<%s style="%s"><color auto="1"/></%s>`, e.tag, excelBorderStyle(e.l), e.tag)127+ fmt.Fprintf(&b, `<%s style="%s">%s</%s>`, e.tag, excelBorderStyle(e.l), borderColorXML(br.Color(e.edge)), e.tag)108128 }109129 b.WriteString(`<diagonal/></border>`)110130 }111131 return b.String() + `</borders>`112132 }113133 134+// borderColorXML is the <color> of a border line: a named color's, as135+// rules write it, or the automatic one.136+func borderColorXML(c sheet.Color) string {137+ if c == sheet.ColorNone {138+ return `<color auto="1"/>`139+ }140+ return fmt.Sprintf(`<color rgb="%s"/>`, ruleRGB[c][0])141+}142+114143 // alignmentXML is a cell format's <alignment>, or "" when it has none.115144 func alignmentXML(st sheet.Style) string {116- if st.Align == sheet.AlignAuto && st.Wrap != sheet.WrapOn {145+ if st.Align == sheet.AlignAuto && st.Wrap != sheet.WrapOn && st.VAlign == sheet.VAlignAuto {117146 return ""118147 }119148 s := `<alignment`120149 if st.Align != sheet.AlignAuto {121150 s += fmt.Sprintf(` horizontal="%s"`, st.Align)122151 }152+ if v := excelVAlign(st.VAlign); v != "" {153+ s += fmt.Sprintf(` vertical="%s"`, v)154+ }123155 if st.Wrap == sheet.WrapOn {124156 s += ` wrapText="1"`125157 }@@ -28,6 +28,8 @@ func TestXLSXLayoutRoundTrip(t *testing.T) {
2828 src.SetBorders(rangeOf(t, "A2:C3"), sheet.BorderAll, sheet.LineThin)2929 src.SetBorders(rangeOf(t, "A2:C3"), sheet.BorderOuter, sheet.LineThick)3030 src.SetBorders(rangeOf(t, "B2"), sheet.BorderBottom, sheet.LineDouble)31+ src.SetBorderStroke(rangeOf(t, "C3"), sheet.BorderRight, sheet.Stroke{Line: sheet.LineThick, Color: sheet.ColorGreen})32+ src.SetStyle(rangeOf(t, "B2"), func(st *sheet.Style) { st.VAlign = sheet.VAlignTop })3133 src.SetRowHeight(4, 4, 3)3234 3335 name := filepath.Join(t.TempDir(), "layout.xlsx")@@ -54,6 +56,10 @@ func TestXLSXLayoutRoundTrip(t *testing.T) {
5456 if st, _ = x.GetStyle(id); st == nil || st.Alignment == nil || !st.Alignment.WrapText {5557 t.Errorf("A2 doesn't wrap in Excel: %+v", st)5658 }59+ id, _ = x.GetCellStyle(ws, "B2")60+ if st, _ = x.GetStyle(id); st == nil || st.Alignment == nil || st.Alignment.Vertical != "top" {61+ t.Errorf("B2 isn't at the top in Excel: %+v", st)62+ }5763 x.Close()5864 5965 res, err := Import(context.Background(), name, Options{})@@ -82,10 +88,11 @@ func TestXLSXLayoutImport(t *testing.T) {
8288 `<row r="3" ht="15.75" customHeight="1"><c r="A3" s="1"/><c r="D3"><v>4</v></c></row>`)8389 p["xl/styles.xml"] = `<styleSheet ` + mainNS + `><fonts count="1"><font/></fonts>` +8490 `<borders count="3"><border><left/><right/><top/><bottom/></border>` +85- `<border><left style="hair"/><right style="mediumDashed"/><top style="double"/><bottom style="thick"/></border>` +91+ `<border><left style="hair"><color auto="1"/></left><right style="mediumDashed"><color rgb="FF808080"/></right>` +92+ `<top style="double"><color rgb="FFFF0000"/></top><bottom style="thick"><color theme="4"/></bottom></border>` +8693 `<border><left style="dashDot"/></border></borders>` +8794 `<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>`95+ `<xf numFmtId="0" fontId="0" borderId="2"/><xf numFmtId="0" fontId="0" borderId="0"><alignment wrapText="1" vertical="center"/></xf></cellXfs></styleSheet>`8996 p["xl/worksheets/sheet1.xml"] = p["xl/worksheets/sheet1.xml"][:len(p["xl/worksheets/sheet1.xml"])-len(`</worksheet>`)] +9097 `<mergeCells count="2"><mergeCell ref="B2:C3"/><mergeCell ref="$E$5:$E$9"/></mergeCells></worksheet>`9198 name := writeParts(t, t.TempDir(), "excel.xlsx", p)@@ -94,9 +101,14 @@ func TestXLSXLayoutImport(t *testing.T) {
94101 t.Fatal(err)95102 }96103 s := res.Sheet97- want := sheet.BordersOf(sheet.LineDouble, sheet.LineThick, sheet.LineThin, sheet.LineThick)104+ // Colors come in as the nearest named one; grays as the text's ink.105+ want := sheet.BordersOf(sheet.LineDouble, sheet.LineThick, sheet.LineThin, sheet.LineThick).106+ WithColor(sheet.EdgeTop, sheet.ColorRed).WithColor(sheet.EdgeBottom, sheet.ColorBlue)98107 if got := s.CellStyle(addr(t, "A1")).Borders; got != want {99- t.Errorf("A1 borders %+v, want %+v", got, want)108+ t.Errorf("A1 borders %x, want %x", uint32(got), uint32(want))109+ }110+ if v := s.CellStyle(addr(t, "A2")).VAlign; v != sheet.VAlignMiddle {111+ t.Errorf("vertical center read as %v", v)100112 }101113 if got := s.CellStyle(addr(t, "B1")).Borders.Left(); got != sheet.LineThin {102114 t.Errorf("a blank cell's border after the row's values: %v", got)@@ -34,6 +34,7 @@ type xlsxXf struct {
3434 numFmt, font, border int3535 applyFont, applyAlign, applyBorder bool3636 align sheet.Align37+ valign sheet.VAlign3738 wrap bool3839 }3940 @@ -54,7 +55,7 @@ func (s *xlsxStyles) style(id int) xlsxStyle {
5455 out.style.Bold, out.style.Italic, out.style.Strikethrough, out.style.Underline = f.bold, f.italic, f.strike, f.underline5556 }5657 if xf.applyAlign {57- out.style.Align = xf.align58+ out.style.Align, out.style.VAlign = xf.align, xf.valign5859 if xf.wrap {5960 out.style.Wrap = sheet.WrapOn6061 }@@ -185,6 +186,7 @@ func (s *xlsxStyles) element(x *xmlStream, se xml.StartElement, section string,
185186 case "right":186187 cur.align = sheet.AlignRight187188 }189+ cur.valign = excelVAligns[attrOr(se, "vertical", "")]188190 }189191 return cur, nil190192 }@@ -41,9 +41,10 @@ func ParseLine(s string) (Line, bool) {
4141 // thin.4242 func Heavier(a, b Line) Line { return max(a, b) }4343 44-// Borders are the lines on a cell's four edges, two bits each, so a45-// style stays eight bytes and a cell fits its allocation size class.46-type Borders uint844+// Borders are the lines on a cell's four edges, two bits each in the45+// low byte, and their colors, four bits each above: ColorNone draws in46+// the ink of the text (see Color and theme.CellBorder).47+type Borders uint324748 4849 // Edge is one of a cell's edges.4950 type Edge uint8@@ -63,9 +64,45 @@ func BordersOf(top, bottom, left, right Line) Borders {
6364 // Line is the line on edge e.6465 func (b Borders) Line(e Edge) Line { return Line(b >> (2 * e) & 3) }6566 66-// With is b with edge e drawn with l.67+// Color is the color of edge e's line.68+func (b Borders) Color(e Edge) Color { return Color(b >> (8 + 4*e) & 15) }69+70+// Stroke is edge e's line and color.71+func (b Borders) Stroke(e Edge) Stroke { return Stroke{b.Line(e), b.Color(e)} }72+73+// With is b with edge e drawn with l, in the color it had; no line has74+// no color.6775 func (b Borders) With(e Edge, l Line) Borders {68- return b&^(3<<(2*e)) | Borders(l&3)<<(2*e)76+ b = b&^(3<<(2*e)) | Borders(l&3)<<(2*e)77+ if l == LineNone {78+ b = b.WithColor(e, ColorNone)79+ }80+ return b81+}82+83+// WithColor is b with edge e's line in color c.84+func (b Borders) WithColor(e Edge, c Color) Borders {85+ return b&^(15<<(8+4*e)) | Borders(c&15)<<(8+4*e)86+}87+88+// WithStroke is b with edge e drawn as st.89+func (b Borders) WithStroke(e Edge, st Stroke) Borders {90+ return b.With(e, st.Line).WithColor(e, st.Color)91+}92+93+// Stroke is a line and its color.94+type Stroke struct {95+ Line Line96+ Color Color97+}98+99+// heavier is the stroke drawn where a and b share an edge: the heavier100+// line, and a's when they are as heavy.101+func heavier(a, b Stroke) Stroke {102+ if b.Line > a.Line {103+ return b104+ }105+ return a69106 }70107 71108 func (b Borders) Top() Line { return b.Line(EdgeTop) }@@ -142,11 +179,11 @@ func (k BorderKind) edges(r Rect, a Addr) edgeSet {
142179 return edgeSet{right: lastCol}143180 }144181 145-// apply sets the edges of e on b to l.146-func (e edgeSet) apply(b *Borders, l Line) {182+// apply sets the edges of e on b to st.183+func (e edgeSet) apply(b *Borders, st Stroke) {147184 for edge, set := range [...]bool{EdgeTop: e.top, EdgeBottom: e.bottom, EdgeLeft: e.left, EdgeRight: e.right} {148185 if set {149- *b = b.With(Edge(edge), l)186+ *b = b.WithStroke(Edge(edge), st)150187 }151188 }152189 }@@ -155,21 +192,63 @@ func (e edgeSet) apply(b *Borders, l Line) {
155192 // line of r for BorderNone, as one undo step. Neighbors outside r lose156193 // the lines on the edges they share with the ones set.157194 func (s *Sheet) SetBorders(r Rect, k BorderKind, l Line) {195+ s.SetBorderStroke(r, k, Stroke{Line: l})196+}197+198+// SetBorderStroke is SetBorders with lines of a color. An outline of199+// whole columns or rows draws the sheet's first and last edges too.200+func (s *Sheet) SetBorderStroke(r Rect, k BorderKind, st Stroke) {158201 label := k.String() + " border " + r.String() // "top border B2:C4"159202 if k <= BorderInner {160203 label = k.String() + " borders " + r.String()161204 }205+ if st.Color != ColorNone {206+ label += " in " + st.Color.String()207+ }162208 if k == BorderNone {163- l, label = LineNone, "remove borders from "+r.String()209+ st, label = Stroke{}, "remove borders from "+r.String()164210 }165211 s.change(label, r, func() {166212 s.eachFormat(r, true, func(a Addr, f *lineFmt) {167- k.edges(r, a).apply(&f.Style.Borders, l)213+ k.edges(r, a).apply(&f.Style.Borders, st)168214 })215+ s.sheetEdges(r, k, st)169216 s.clearFacing(r, k)170217 })171218 }172219 220+// sheetEdges draws the edges of whole columns or rows that lie on the221+// sheet's own edges, which k.edges leaves out along the axis they cover222+// whole: the top of row 1 and the bottom of the last row under whole223+// columns, the left of column A and the right of the last column beside224+// whole rows. Across the whole sheet those are the lines of its first225+// and last rows and columns; otherwise the cells along them.226+func (s *Sheet) sheetEdges(r Rect, k BorderKind, st Stroke) {227+ var e edgeSet228+ switch k {229+ case BorderOuter:230+ e = edgeSet{true, true, true, true}231+ case BorderTop, BorderBottom, BorderLeft, BorderRight:232+ e = edgeSet{top: k == BorderTop, bottom: k == BorderBottom, left: k == BorderLeft, right: k == BorderRight}233+ default:234+ return235+ }236+ last := Addr{Col: MaxCols - 1, Row: MaxRows - 1}237+ set := func(on bool, n Rect, edge edgeSet) {238+ if on {239+ s.eachFormat(n, true, func(_ Addr, f *lineFmt) { edge.apply(&f.Style.Borders, st) })240+ }241+ }242+ if r.AllRows() {243+ set(e.top, Rect{From: Addr{Col: r.From.Col}, To: Addr{Col: r.To.Col}}, edgeSet{top: true})244+ set(e.bottom, Rect{From: Addr{Col: r.From.Col, Row: last.Row}, To: Addr{Col: r.To.Col, Row: last.Row}}, edgeSet{bottom: true})245+ }246+ if r.AllCols() {247+ set(e.left, Rect{From: Addr{Row: r.From.Row}, To: Addr{Row: r.To.Row}}, edgeSet{left: true})248+ set(e.right, Rect{From: Addr{Col: last.Col, Row: r.From.Row}, To: Addr{Col: last.Col, Row: r.To.Row}}, edgeSet{right: true})249+ }250+}251+173252 // clearFacing removes the lines of the neighbors of r on the edges that174253 // setting k on r draws.175254 func (s *Sheet) clearFacing(r Rect, k BorderKind) {@@ -181,7 +260,7 @@ func (s *Sheet) clearFacing(r Rect, k BorderKind) {
181260 if !n.From.Valid() || !n.To.Valid() {182261 return183262 }184- s.eachFormat(n, true, func(_ Addr, f *lineFmt) { e.apply(&f.Style.Borders, LineNone) })263+ s.eachFormat(n, true, func(_ Addr, f *lineFmt) { e.apply(&f.Style.Borders, Stroke{}) })185264 }186265 if outer.top && !r.AllRows() {187266 strip(Addr{Col: r.From.Col, Row: r.From.Row - 1}, Addr{Col: r.To.Col, Row: r.From.Row - 1}, edgeSet{bottom: true})@@ -199,20 +278,27 @@ func (s *Sheet) clearFacing(r Rect, k BorderKind) {
199278 200279 // EdgeAbove is the line on the edge between the cell at a and the one201280 // above it: the heavier of their facing lines.202-func (s *Sheet) EdgeAbove(a Addr) Line {203- l := s.CellStyle(a).Borders.Top()281+func (s *Sheet) EdgeAbove(a Addr) Line { return s.StrokeAbove(a).Line }282+283+// EdgeLeft is the line on the edge between the cell at a and the one to284+// its left.285+func (s *Sheet) EdgeLeft(a Addr) Line { return s.StrokeLeft(a).Line }286+287+// StrokeAbove is EdgeAbove with its color: the heavier line's, or the288+// cell's own where the lines are as heavy.289+func (s *Sheet) StrokeAbove(a Addr) Stroke {290+ st := s.CellStyle(a).Borders.Stroke(EdgeTop)204291 if a.Row > 0 {205- l = Heavier(l, s.CellStyle(Addr{Col: a.Col, Row: a.Row - 1}).Borders.Bottom())292+ st = heavier(st, s.CellStyle(Addr{Col: a.Col, Row: a.Row - 1}).Borders.Stroke(EdgeBottom))206293 }207- return l294+ return st208295 }209296 210-// EdgeLeft is the line on the edge between the cell at a and the one to211-// its left.212-func (s *Sheet) EdgeLeft(a Addr) Line {213- l := s.CellStyle(a).Borders.Left()297+// StrokeLeft is EdgeLeft with its color.298+func (s *Sheet) StrokeLeft(a Addr) Stroke {299+ st := s.CellStyle(a).Borders.Stroke(EdgeLeft)214300 if a.Col > 0 {215- l = Heavier(l, s.CellStyle(Addr{Col: a.Col - 1, Row: a.Row}).Borders.Right())301+ st = heavier(st, s.CellStyle(Addr{Col: a.Col - 1, Row: a.Row}).Borders.Stroke(EdgeRight))216302 }217- return l303+ return st218304 }@@ -23,6 +23,9 @@ type fileCell struct {
2323 // and show the text overflowing, without lines.2424 Wrap string `json:"wrap,omitempty"` // "wrap" or "clip"2525 Borders *fileBorders `json:"borders,omitempty"`26+ // VAlign needs no version bump either: earlier builds show the text27+ // at the bottom.28+ VAlign string `json:"valign,omitempty"` // "top", "middle" or "bottom"2629 // Note needs no version bump: earlier builds ignore it and drop the2730 // note.2831 Note string `json:"note,omitempty"`@@ -42,6 +45,7 @@ func encodeCell(c *Cell) (json.RawMessage, error) {
4245 Align: c.Style.Align.String(),4346 Own: c.Style.own,4447 Wrap: c.Style.Wrap.String(),48+ VAlign: c.Style.VAlign.String(),4549 Borders: encodeBorders(c.Style.Borders),4650 Note: c.Note,4751 }@@ -98,28 +102,40 @@ func decodeFormatted(fc fileCell) (string, Format, Style, error) {
98102 if !ok {99103 return "", Format{}, Style{}, fmt.Errorf("unknown wrapping %q", fc.Wrap)100104 }105+ va, ok := ParseVAlign(fc.VAlign)106+ if !ok {107+ return "", Format{}, Style{}, fmt.Errorf("unknown vertical alignment %q", fc.VAlign)108+ }101109 b, err := decodeBorders(fc.Borders)102110 if err != nil {103111 return "", Format{}, Style{}, err104112 }105- st := Style{Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strikethrough, Align: al, Wrap: wr, Borders: b, own: fc.Own}113+ st := Style{Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strikethrough, Align: al, Wrap: wr, VAlign: va, Borders: b, own: fc.Own}106114 return fc.Input, f, st, nil107115 }108116 109-// fileBorders are a cell's borders in the file, each edge's line by name:110-// {"top": "thin", "left": "double"}.117+// fileBorders are a cell's borders in the file, each edge's line by name118+// and, when it has one, its color: {"top": "thin", "left": "double",119+// "leftColor": "red"}. Earlier builds ignore the colors and draw the120+// lines in the text's ink.111121 type fileBorders struct {112- Top string `json:"top,omitempty"`113- Bottom string `json:"bottom,omitempty"`114- Left string `json:"left,omitempty"`115- Right string `json:"right,omitempty"`122+ Top string `json:"top,omitempty"`123+ Bottom string `json:"bottom,omitempty"`124+ Left string `json:"left,omitempty"`125+ Right string `json:"right,omitempty"`126+ TopColor string `json:"topColor,omitempty"`127+ BottomColor string `json:"bottomColor,omitempty"`128+ LeftColor string `json:"leftColor,omitempty"`129+ RightColor string `json:"rightColor,omitempty"`116130 }117131 118132 func encodeBorders(b Borders) *fileBorders {119133 if b.IsZero() {120134 return nil121135 }122- return &fileBorders{Top: b.Top().String(), Bottom: b.Bottom().String(), Left: b.Left().String(), Right: b.Right().String()}136+ return &fileBorders{Top: b.Top().String(), Bottom: b.Bottom().String(), Left: b.Left().String(), Right: b.Right().String(),137+ TopColor: b.Color(EdgeTop).String(), BottomColor: b.Color(EdgeBottom).String(),138+ LeftColor: b.Color(EdgeLeft).String(), RightColor: b.Color(EdgeRight).String()}123139 }124140 125141 func decodeBorders(fb *fileBorders) (Borders, error) {@@ -127,12 +143,20 @@ func decodeBorders(fb *fileBorders) (Borders, error) {
127143 if fb == nil {128144 return b, nil129145 }130- for edge, name := range [...]string{EdgeTop: fb.Top, EdgeBottom: fb.Bottom, EdgeLeft: fb.Left, EdgeRight: fb.Right} {146+ lines := [...]string{EdgeTop: fb.Top, EdgeBottom: fb.Bottom, EdgeLeft: fb.Left, EdgeRight: fb.Right}147+ colors := [...]string{EdgeTop: fb.TopColor, EdgeBottom: fb.BottomColor, EdgeLeft: fb.LeftColor, EdgeRight: fb.RightColor}148+ for edge, name := range lines {131149 l, ok := ParseLine(name)132150 if !ok {133151 return 0, fmt.Errorf("unknown border line %q", name)134152 }135- b = b.With(Edge(edge), l)153+ c, ok := ParseColor(colors[edge])154+ if !ok {155+ return 0, fmt.Errorf("unknown border color %q", colors[edge])156+ }157+ if l != LineNone {158+ b = b.WithStroke(Edge(edge), Stroke{l, c})159+ }136160 }137161 return b, nil138162 }@@ -191,6 +191,8 @@ func streamSeeds(t testing.TB) [][]byte {
191191 s.SetStyle(NewRect(Addr{Col: 4}, Addr{Col: 4, Row: MaxRows - 1}), func(st *Style) { st.Wrap = WrapClip })192192 s.SetBorders(NewRect(Addr{Row: 1}, Addr{Col: 2, Row: 3}), BorderAll, LineThin)193193 s.SetBorders(NewRect(Addr{Row: 1}, Addr{Col: 2, Row: 3}), BorderOuter, LineDouble)194+ s.SetBorderStroke(NewRect(Addr{Col: 3, Row: 1}, Addr{Col: 3, Row: 2}), BorderRight, Stroke{Line: LineThick, Color: ColorMagenta})195+ s.SetStyle(NewRect(Addr{Row: 6}, Addr{Col: 1, Row: 6}), func(st *Style) { st.VAlign = VAlignMiddle })194196 s.SetRowHeight(6, 7, 3)195197 s.LoadMerge(NewRect(Addr{Col: 6}, Addr{Col: 8, Row: 1}))196198 one := []byte(written(t, s.Book()))@@ -219,6 +221,8 @@ func streamSeeds(t testing.TB) [][]byte {
219221 []byte(`{"version": 2, "cells": {"A1": "1"}} trailing`),220222 []byte(`{"version": 2, "heights": {"2": 3, "9": 99}, "merges": ["A1:B1", "B1:C2", "D4"], "cells": {"A1": {"input": "5", "wrap": "wrap", "borders": {"top": "thin", "right": "double"}}}}`),221223 []byte(`{"version": 2, "cells": {"A1": {"input": "x", "wrap": "sideways"}, "B1": {"borders": {"left": "dotted"}}}}`),224+ []byte(`{"version": 2, "cells": {"A1": {"input": "5", "valign": "top", "borders": {"top": "thick", "topColor": "red", "left": "thin", "leftColor": "blue"}}, "B1": {"borders": {"rightColor": "green"}}}}`),225+ []byte(`{"version": 2, "cells": {"A1": {"input": "x", "valign": "sideways"}, "B1": {"borders": {"left": "thin", "leftColor": "mauve"}}}}`),222226 []byte(`{"version": 2, "heights": {"0": 2}, "merges": ["A1:"], "cells": {}}`),223227 []byte(`null`), []byte(`[]`), []byte(`{"version": 2, "cells": {"A1": "1",}}`), []byte(`{"version": 2, "cells": {"A1"`),224228 }@@ -0,0 +1,95 @@
1+package sheet2+3+import (4+ "strings"5+ "testing"6+ "unsafe"7+)8+9+// A Cell stays in its allocation size class with a style of twelve10+// bytes, which fits in what the flags after it would pad.11+func TestCellSize(t *testing.T) {12+ if n := unsafe.Sizeof(Cell{}); n > 256 {13+ t.Errorf("Cell is %d bytes", n)14+ }15+ if n := unsafe.Sizeof(Style{}); n > 12 {16+ t.Errorf("Style is %d bytes", n)17+ }18+}19+20+// Vertical alignment and border colors are saved, undone and cleared21+// with the rest of a cell's style.22+func TestVAlignAndBorderColors(t *testing.T) {23+ s := New()24+ s.Set(at("B2"), "x")25+ s.SetStyle(rng("B2"), func(st *Style) { st.VAlign = VAlignTop })26+ s.SetBorderStroke(rng("B2:C3"), BorderOuter, Stroke{Line: LineThick, Color: ColorRed})27+ s.SetBorderStroke(rng("B2:C3"), BorderInner, Stroke{Line: LineThin})28+ b := s.CellStyle(at("B2")).Borders29+ if b.Stroke(EdgeTop) != (Stroke{LineThick, ColorRed}) || b.Stroke(EdgeBottom) != (Stroke{Line: LineThin}) {30+ t.Errorf("B2 borders %x", uint32(b))31+ }32+ if st := s.StrokeAbove(at("B4")); st != (Stroke{LineThick, ColorRed}) {33+ t.Errorf("below the outline %+v", st)34+ }35+ got := roundTrip(t, s)36+ if st := got.CellStyle(at("B2")); st.VAlign != VAlignTop || st.Borders != b {37+ t.Errorf("read back %+v", st)38+ }39+ var buf strings.Builder40+ if err := s.Write(&buf); err != nil {41+ t.Fatal(err)42+ }43+ if !strings.Contains(buf.String(), `"valign":"top"`) || !strings.Contains(buf.String(), `"topColor":"red"`) {44+ t.Errorf("file:\n%s", buf.String())45+ }46+ s.Book().Undo()47+ s.Book().Undo()48+ if s.CellStyle(at("B2")).Borders != 0 {49+ t.Error("undo kept the borders")50+ }51+ // A line removed takes its color with it.52+ if b := Borders(0).WithStroke(EdgeLeft, Stroke{LineThin, ColorBlue}).With(EdgeLeft, LineNone); b != 0 {53+ t.Errorf("no line keeps color %x", uint32(b))54+ }55+ for v, want := range map[VAlign][3]int{VAlignAuto: {3, 3, 1}, VAlignTop: {0, 0, 0}, VAlignMiddle: {1, 0, 1}, VAlignBottom: {3, 3, 3}} {56+ if got := [3]int{v.Offset(1, 4, false), v.Offset(3, 4, false) * 3, v.Offset(1, 4, true)}; got != want {57+ t.Errorf("%v offsets %v, want %v", v, got, want)58+ }59+ }60+ for _, name := range []string{"", "top", "middle", "bottom"} {61+ if v, ok := ParseVAlign(name); !ok || v.String() != name {62+ t.Errorf("valign %q", name)63+ }64+ }65+}66+67+// The outline of whole columns or rows draws the sheet's first and last68+// edges, as lines where it covers the whole sheet.69+func TestSheetEdgeBorders(t *testing.T) {70+ s := New()71+ cols := Rect{From: Addr{Col: 1}, To: Addr{Col: 2, Row: MaxRows - 1}}72+ s.SetBorders(cols, BorderOuter, LineThick)73+ if s.EdgeAbove(Addr{Col: 1}) != LineThick || s.EdgeAbove(Addr{Col: 2}) != LineThick || s.EdgeAbove(Addr{Col: 3}) != LineNone {74+ t.Error("no top edge over whole columns")75+ }76+ if s.CellStyle(Addr{Col: 2, Row: MaxRows - 1}).Borders.Bottom() != LineThick || s.EdgeAbove(Addr{Col: 1, Row: 5}) != LineNone {77+ t.Error("bottom edge of whole columns")78+ }79+ if s.EdgeLeft(Addr{Col: 1, Row: 7}) != LineThick || s.EdgeLeft(Addr{Col: 3, Row: 7}) != LineThick {80+ t.Error("sides of whole columns")81+ }82+ rows := Rect{From: Addr{Row: 4}, To: Addr{Col: MaxCols - 1, Row: 5}}83+ s.SetBorders(rows, BorderLeft, LineDouble)84+ if s.EdgeLeft(Addr{Row: 4}) != LineDouble || s.EdgeLeft(Addr{Row: 6}) != LineNone {85+ t.Error("left edge of whole rows")86+ }87+ all := Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}}88+ s.SetBorders(all, BorderOuter, LineThin)89+ if s.EdgeAbove(Addr{Col: 900}) != LineThin || s.EdgeLeft(Addr{Row: 900}) != LineThin {90+ t.Error("the whole sheet's outline")91+ }92+ if n := s.Len(); n > 16 {93+ t.Errorf("%d cells for the outlines", n)94+ }95+}@@ -192,6 +192,22 @@ func (s *Sheet) Merge(r Rect, k MergeKind) error {
192192 return nil193193 }194194 195+// MergeAcross returns a merge the line after the first n rows (or196+// columns) would cut through, as freezing them would, and whether there197+// is one.198+func (s *Sheet) MergeAcross(rows bool, n int) (Rect, bool) {199+ for _, m := range s.view.merges {200+ from, to := m.From.Col, m.To.Col201+ if rows {202+ from, to = m.From.Row, m.To.Row203+ }204+ if n > 0 && from < n && to >= n {205+ return m, true206+ }207+ }208+ return Rect{}, false209+}210+195211 // Unmerge splits the merges overlapping r back into cells, as one undo196212 // step, and returns how many there were.197213 func (s *Sheet) Unmerge(r Rect) int {@@ -28,8 +28,9 @@ type Cell struct {
2828 2929 // Format and Style are plain values, so copying a Cell copies its3030 // formatting. They survive clearing the contents, as in Sheets.31+ // Style is kept at the end, with the flags, where it fits in what32+ // alignment would otherwise pad, so a Cell stays 256 bytes.3133 Format Format32- Style Style3334 3435 // Note is the cell's note, as Sheets' Insert > Note: text shown when3536 // the cell is active or hovered. Like formatting, it survives clearing@@ -42,9 +43,10 @@ type Cell struct {
4243 ranges []Rect // range references in expr to its own sheet4344 xrefs []xref // references that name a sheet, e.g. Sheet2!A14445 names []string // names in expr, as keys of Workbook.names45- volatile bool // expr calls TODAY, NOW, RAND...46- derived bool // a pivot table's result, owned by the engine: see pivot.go47- spilled bool // part of an array a formula spills, owned by it: see spill.go46+ Style Style47+ volatile bool // expr calls TODAY, NOW, RAND...48+ derived bool // a pivot table's result, owned by the engine: see pivot.go49+ spilled bool // part of an array a formula spills, owned by it: see spill.go4850 }4951 5052 // Spilled reports whether the cell shows part of an array another@@ -78,18 +78,65 @@ func ParseWrap(s string) (Wrap, bool) {
7878 return WrapOverflow, false7979 }8080 81-// Style is a cell's text style, with its wrapping and borders. It is a82-// plain value so cells can be copied freely.81+// VAlign is where a cell's text sits in a row taller than it, as82+// Sheets' Format > Alignment's top, middle and bottom.83+type VAlign uint884+85+const (86+ VAlignAuto VAlign = iota // the bottom, as in Sheets; the middle of a merged cell87+ VAlignTop //88+ VAlignMiddle //89+ VAlignBottom //90+)91+92+var valignNames = [...]string{"", "top", "middle", "bottom"}93+94+// String returns the vertical alignment's name as stored in files.95+func (v VAlign) String() string {96+ if int(v) < len(valignNames) {97+ return valignNames[v]98+ }99+ return ""100+}101+102+// ParseVAlign is the inverse of VAlign.String.103+func ParseVAlign(s string) (VAlign, bool) {104+ for i, n := range valignNames {105+ if n == s {106+ return VAlign(i), true107+ }108+ }109+ return VAlignAuto, false110+}111+112+// Offset is the line, of n a row has, where the first of k lines of113+// text starts: the top, the middle or the bottom, the bottom for114+// VAlignAuto unless merged is set, which centers it.115+func (v VAlign) Offset(k, n int, merged bool) int {116+ switch {117+ case v == VAlignTop:118+ return 0119+ case v == VAlignMiddle, v == VAlignAuto && merged:120+ return max(n-k, 0) / 2121+ }122+ return max(n-k, 0)123+}124+125+// Style is a cell's text style, with its wrapping, alignment and126+// borders. It is a plain value so cells can be copied freely, twelve127+// bytes, which the end of a Cell has room for (see Cell).83128 type Style struct {84129 Bold, Italic, Underline, Strikethrough bool85130 Align Align86131 Wrap Wrap87- Borders Borders // see borders.go132+ VAlign VAlign88133 89134 // own marks a cell's format and style as wholly its own, not falling90135 // back on its row's or column's even where they are Automatic or91136 // plain; see lines.go.92137 own bool138+139+ Borders Borders // see borders.go93140 }94141 95142 // IsZero reports whether s is the default style.