cat / 012
clone

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

Read and write conditional formats and data validation in XLSX

Dami committed 8365022
Single-color rules go out as cellIs, text, blank, timePeriod and
expression rules with dxf styles, color scales as colorScale, and
validation as list, decimal, date, textLength and custom rules
(checkboxes as a TRUE,FALSE list), in Sheets' palette colors. Excel's
rules come back with colors mapped to the nearest named color,
including validation in the 2010 extension; data bars, icon sets and
other rules 012 has no equivalent for are left out with a note.

The terminal's palette is asked for at startup, so color scales blend
its colors in the terminal theme.
commit
83650221725fa24a63adca746fec1ae890c173d2
parent
4358f56c94a3fb767db810e413bc36e3da49085e
author
Dami <dami.etoile@gmail.com>
14 files changed, +1196 -22
Minternal/fileio/snapshot.go8+-
Minternal/fileio/xlsx.go1+
Minternal/fileio/xlsxbook.go3+-
Minternal/fileio/xlsximport.go4+-
Ainternal/fileio/xlsxrules.go277++++++++++++++++++++++++++++
Ainternal/fileio/xlsxrules_test.go213+++++++++++++++++++++
Ainternal/fileio/xlsxrulesimport.go301++++++++++++++++++++++++++++++
Ainternal/fileio/xlsxrulesread.go297++++++++++++++++++++++++++++++
Minternal/fileio/xlsxsheet.go6+
Minternal/fileio/xlsxstyles.go13+-
Minternal/fileio/xlsxviewread.go12+-
Minternal/fileio/xlsxwrite.go47+++++-
Minternal/sheet/validation.go8+-
Minternal/ui/graphics.go28++-
M internal/fileio/snapshot.go +7 -1
M internal/fileio/xlsx.go +1 -0
M internal/fileio/xlsxbook.go +2 -1
M internal/fileio/xlsximport.go +3 -1
A internal/fileio/xlsxrules.go +277 -0
@@ -0,0 +1,277 @@
1+package fileio2+3+import (4+ "bufio"5+ "fmt"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/formula"10+ "github.com/FelineStateMachine/012/internal/numfmt"11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// A sheet's rules go out as Excel's conditional formatting (single-color15+// rules with a differential style, dxf, and color scales) and data16+// validation, after the autoFilter as the schema orders them. The rules17+// are tried in order and the first that applies wins, so each single-18+// color rule stops the ones after it (stopIfTrue) and its priority is its19+// place. Named colors are written in the colors of Sheets' palette.20+//21+// 012 Excel22+// is empty, is not empty containsBlanks, notContainsBlanks23+// text contains, starts ... containsText, notContainsText, beginsWith, endsWith24+// text is exactly cellIs equal to the text25+// date is, before, after timePeriod (today, tomorrow, yesterday) or an expression on INT(cell)26+// comparisons, between cellIs27+// custom formula expression28+// color scale colorScale29+// checkbox list validation of TRUE,FALSE30+// dropdown, from a range list validation of the items, or of the range31+// number, date, text length decimal, date, textLength validation32+// custom formula custom validation33+34+// ruleRGB are the colors named rule colors are written in, for text, a35+// fill and a color scale's points.36+var ruleRGB = [sheet.NumColors][3]string{37+ {},38+ {"FFCC0000", "FFF4CCCC", "FFF8696B"}, // red39+ {"FFBF9000", "FFFFF2CC", "FFFFEB84"}, // yellow40+ {"FF38761D", "FFD9EAD3", "FF63BE7B"}, // green41+ {"FF134F5C", "FFD0E0E3", "FF67C9D3"}, // cyan42+ {"FF1155CC", "FFCFE2F3", "FF5A8AC6"}, // blue43+ {"FF741B47", "FFEAD1DC", "FFC27BC0"}, // magenta44+}45+46+// ruleNotes counts rules Excel has no equivalent for, keeping the first47+// one's range as an example.48+type ruleNotes struct {49+ formats, validations valueCount50+}51+52+// writeRules writes a sheet's conditional formats and data validation.53+func (w *xlsxWriter) writeRules(bw *bufio.Writer, ws string, snap *Snapshot) {54+ for i, f := range snap.CondFormats {55+ rule, ok := w.cfRule(f, i+1)56+ if !ok {57+ w.rules.formats.add(w.multi, ws, f.Ranges[0].From)58+ continue59+ }60+ fmt.Fprintf(bw, `<conditionalFormatting sqref="%s">%s</conditionalFormatting>`, sqref(f.Ranges), rule)61+ }62+ var dv strings.Builder63+ n := 064+ for _, v := range snap.Validations {65+ x, ok := w.validation(v)66+ if !ok {67+ w.rules.validations.add(w.multi, ws, v.Ranges[0].From)68+ continue69+ }70+ dv.WriteString(x)71+ n++72+ }73+ if n > 0 {74+ fmt.Fprintf(bw, `<dataValidations count="%d">%s</dataValidations>`, n, dv.String())75+ }76+}77+78+// sqref writes ranges as Excel's sqref: "A1:A9 C1:C9".79+func sqref(rs []sheet.Rect) string {80+ parts := make([]string, len(rs))81+ for i, r := range rs {82+ parts[i] = excelRect(r)83+ }84+ return strings.Join(parts, " ")85+}86+87+// excelText quotes text as a formula's string.88+func excelText(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }89+90+func excelNum(n float64) string { return strconv.FormatFloat(n, 'f', -1, 64) }91+92+// excelArg writes a rule's value as an Excel formula: a formula93+// translated, a number (a date as Excel's serial), or text.94+func (w *xlsxWriter) excelArg(arg string) (string, bool) {95+ arg = strings.TrimSpace(arg)96+ if sheet.IsFormulaEntry(arg) {97+ return toExcelFormula(arg, w.renamed)98+ }99+ if n, f, ok := sheet.ParseValue(arg); ok {100+ return excelNum(toExcelSerial(n, f)), true101+ }102+ return excelText(arg), true103+}104+105+// cellIsOps are Excel's names of the comparisons.106+var cellIsOps = map[sheet.RuleOp]string{sheet.RuleGreater: "greaterThan", sheet.RuleGreaterEq: "greaterThanOrEqual",107+ sheet.RuleLess: "lessThan", sheet.RuleLessEq: "lessThanOrEqual", sheet.RuleEqual: "equal",108+ sheet.RuleNotEqual: "notEqual", sheet.RuleBetween: "between", sheet.RuleNotBetween: "notBetween"}109+110+// textRules are the text tests Excel has a rule type for, with the111+// formula each is written with, on the cell and the text.112+var textRules = map[sheet.RuleOp]struct{ typ, op, formula string }{113+ sheet.RuleContains: {"containsText", "containsText", `NOT(ISERROR(SEARCH(%[2]s,%[1]s)))`},114+ sheet.RuleNotContains: {"notContainsText", "notContains", `ISERROR(SEARCH(%[2]s,%[1]s))`},115+ sheet.RuleStartsWith: {"beginsWith", "beginsWith", `LEFT(%[1]s,LEN(%[2]s))=%[2]s`},116+ sheet.RuleEndsWith: {"endsWith", "endsWith", `RIGHT(%[1]s,LEN(%[2]s))=%[2]s`},117+}118+119+// cfRule writes rule f as a <cfRule> of priority p, or reports false120+// when Excel can't take it.121+func (w *xlsxWriter) cfRule(f sheet.CondFormat, p int) (string, bool) {122+ if f.IsScale() {123+ return colorScaleXML(f.Scale, p), true124+ }125+ cell := excelRect(sheet.Rect{From: f.Ranges[0].From, To: f.Ranges[0].From})126+ head := fmt.Sprintf(`<cfRule type="%%s" dxfId="%d" priority="%d" stopIfTrue="1"`, w.styles.dxf(f.Style), p)127+ formulas := func(fs ...string) string {128+ var b strings.Builder129+ for _, fx := range fs {130+ b.WriteString("<formula>" + escapeXML(fx, false) + "</formula>")131+ }132+ return b.String()133+ }134+ arg := strings.TrimSpace(f.Args[0])135+ switch op := f.Op; {136+ case op == sheet.RuleEmpty:137+ return fmt.Sprintf(head, "containsBlanks") + ">" + formulas("LEN(TRIM("+cell+"))=0") + "</cfRule>", true138+ case op == sheet.RuleNotEmpty:139+ return fmt.Sprintf(head, "notContainsBlanks") + ">" + formulas("LEN(TRIM("+cell+"))>0") + "</cfRule>", true140+ case textRules[op].typ != "" && !sheet.IsFormulaEntry(arg):141+ t := textRules[op]142+ return fmt.Sprintf(head, t.typ) + fmt.Sprintf(` operator="%s" text="%s">`, t.op, escapeXML(arg, true)) +143+ formulas(fmt.Sprintf(t.formula, cell, excelText(arg))) + "</cfRule>", true144+ case op == sheet.RuleExactly && !sheet.IsFormulaEntry(arg):145+ return fmt.Sprintf(head, "cellIs") + ` operator="equal">` + formulas(excelText(arg)) + "</cfRule>", true146+ case op == sheet.RuleDateIs || op == sheet.RuleDateBefore || op == sheet.RuleDateAfter:147+ return w.dateRule(head, cell, op, arg)148+ case op == sheet.RuleFormula:149+ fx, ok := toExcelFormula(arg, w.renamed)150+ return fmt.Sprintf(head, "expression") + ">" + formulas(fx) + "</cfRule>", ok151+ case cellIsOps[op] != "":152+ lo, ok1 := w.excelArg(f.Args[0])153+ fs := []string{lo}154+ ok2 := true155+ if op.Args() == 2 {156+ var hi string157+ hi, ok2 = w.excelArg(f.Args[1])158+ fs = append(fs, hi)159+ }160+ return fmt.Sprintf(head, "cellIs") + fmt.Sprintf(` operator="%s">`, cellIsOps[op]) + formulas(fs...) + "</cfRule>", ok1 && ok2161+ }162+ return "", false // a text test on a formula's value163+}164+165+// dateRule writes a date test: a timePeriod for "Date is today" and the166+// like, else an expression comparing the cell's day.167+func (w *xlsxWriter) dateRule(head, cell string, op sheet.RuleOp, arg string) (string, bool) {168+ days := map[string]string{"today": "TODAY()", "tomorrow": "TODAY()+1", "yesterday": "TODAY()-1"}169+ word := strings.ToLower(arg)170+ if op == sheet.RuleDateIs && days[word] != "" {171+ return fmt.Sprintf(head, "timePeriod") + fmt.Sprintf(` timePeriod="%s"><formula>FLOOR(%s,1)=%s</formula></cfRule>`, word, cell, days[word]), true172+ }173+ day := days[word]174+ if day == "" {175+ n, _, ok := sheet.ParseValue(arg)176+ if !ok {177+ return "", false178+ }179+ y, m, d := numfmt.Civil(int64(n))180+ day = fmt.Sprintf("DATE(%d,%d,%d)", y, m, d)181+ }182+ cmp := map[sheet.RuleOp]string{sheet.RuleDateIs: "=", sheet.RuleDateBefore: "<", sheet.RuleDateAfter: ">"}[op]183+ return fmt.Sprintf(head, "expression") + ">" + "<formula>" + escapeXML("INT("+cell+")"+cmp+day, false) + "</formula></cfRule>", true184+}185+186+// colorScaleXML writes a color scale of priority p.187+func colorScaleXML(ps []sheet.ScalePoint, p int) string {188+ var cfvo, colors strings.Builder189+ for _, pt := range ps {190+ fmt.Fprintf(&cfvo, `<cfvo type="%s"`, pt.Kind)191+ if pt.Kind.TakesValue() {192+ fmt.Fprintf(&cfvo, ` val="%s"`, escapeXML(strings.TrimSpace(pt.Value), true))193+ }194+ cfvo.WriteString("/>")195+ fmt.Fprintf(&colors, `<color rgb="%s"/>`, ruleRGB[pt.Color][2])196+ }197+ return fmt.Sprintf(`<cfRule type="colorScale" priority="%d"><colorScale>%s%s</colorScale></cfRule>`, p, cfvo.String(), colors.String())198+}199+200+// validTypes are Excel's names of the kinds that compare.201+var validTypes = map[sheet.ValidKind]string{sheet.ValidNumber: "decimal", sheet.ValidDate: "date", sheet.ValidLength: "textLength"}202+203+// validation writes rule v as a <dataValidation>, or reports false when204+// Excel can't take it: list items with commas or quotes, or longer than205+// Excel's 255 characters.206+func (w *xlsxWriter) validation(v sheet.Validation) (string, bool) {207+ typ, op := "list", ""208+ var fs []string209+ ok := true210+ switch v.Kind {211+ case sheet.ValidList:212+ list := strings.Join(v.Items, ",")213+ ok = len(list) <= 255 && !strings.ContainsAny(strings.Join(v.Items, ""), `,"`)214+ fs = []string{excelText(list)}215+ case sheet.ValidCheckbox:216+ fs = []string{`"TRUE,FALSE"`}217+ case sheet.ValidRange:218+ name, r, rok := v.SourceRange()219+ ref, _ := strings.CutPrefix(excelRange("x", r), sheet.QuoteSheet("x")+"!")220+ if name != "" {221+ if to, renamed := w.renamed[formula.SheetKey(name)]; renamed {222+ name = to223+ }224+ ref = excelRange(name, r)225+ }226+ fs, ok = []string{ref}, rok227+ case sheet.ValidFormula:228+ typ = "custom"229+ fx, fok := toExcelFormula(v.Args[0], w.renamed)230+ fs, ok = []string{fx}, fok231+ default:232+ typ, op = validTypes[v.Kind], cellIsOps[v.Op]233+ if v.Kind == sheet.ValidDate && v.Op == sheet.RuleNone {234+ op, fs = "greaterThan", []string{"0"} // any date235+ break236+ }237+ for k := range v.Op.Args() {238+ fx, aok := w.excelArg(v.Args[k])239+ fs, ok = append(fs, fx), ok && aok240+ }241+ }242+ if !ok {243+ return "", false244+ }245+ var b strings.Builder246+ fmt.Fprintf(&b, `<dataValidation type="%s"`, typ)247+ if op != "" {248+ fmt.Fprintf(&b, ` operator="%s"`, op)249+ }250+ style := "stop"251+ if !v.Reject {252+ style = "warning"253+ }254+ fmt.Fprintf(&b, ` allowBlank="1" showErrorMessage="1" errorStyle="%s"`, style)255+ if h := strings.TrimSpace(v.Help); h != "" {256+ fmt.Fprintf(&b, ` showInputMessage="1" prompt="%s" error="%s"`, escapeXML(h, true), escapeXML(h, true))257+ }258+ fmt.Fprintf(&b, ` sqref="%s">`, sqref(v.Ranges))259+ for k, fx := range fs {260+ fmt.Fprintf(&b, "<formula%d>%s</formula%d>", k+1, escapeXML(fx, false), k+1)261+ }262+ return b.String() + "</dataValidation>", true263+}264+265+// rulesNotes says which rules were left out.266+func (w *xlsxWriter) rulesNotes() []string {267+ var out []string268+ if n := w.rules.formats; n.n > 0 {269+ out = append(out, fmt.Sprintf("%s with no Excel equivalent left out, e.g. %s",270+ count(n.n, "conditional format", "conditional formats"), n.example))271+ }272+ if n := w.rules.validations; n.n > 0 {273+ out = append(out, fmt.Sprintf("%s Excel can't hold left out, e.g. %s (list items with commas or quotes, or over 255 characters, or a formula with no Excel equivalent)",274+ count(n.n, "data validation rule", "data validation rules"), n.example))275+ }276+ return out277+}
A internal/fileio/xlsxrules_test.go +213 -0
@@ -0,0 +1,213 @@
1+package fileio2+3+import (4+ "context"5+ "reflect"6+ "strings"7+ "testing"8+9+ "github.com/xuri/excelize/v2"10+11+ "github.com/FelineStateMachine/012/internal/sheet"12+)13+14+// lines are rules as the file writes them, for comparing.15+func formatLines(s *sheet.Sheet) []string {16+ var out []string17+ for _, f := range s.CondFormats() {18+ out = append(out, f.JSON())19+ }20+ return out21+}22+23+func validationLines(s *sheet.Sheet) []string {24+ var out []string25+ for _, v := range s.Validations() {26+ out = append(out, v.JSON())27+ }28+ return out29+}30+31+func mustLoad(t *testing.T, s *sheet.Sheet, formats, validations []string) {32+ t.Helper()33+ var fs []sheet.CondFormat34+ for _, l := range formats {35+ f, err := sheet.ParseCondFormat(l)36+ if err != nil {37+ t.Fatalf("%s: %v", l, err)38+ }39+ fs = append(fs, f)40+ }41+ var vs []sheet.Validation42+ for _, l := range validations {43+ v, err := sheet.ParseValidation(l)44+ if err != nil {45+ t.Fatalf("%s: %v", l, err)46+ }47+ vs = append(vs, v)48+ }49+ if s.LoadCondFormats(fs)+s.LoadValidations(vs) > 0 {50+ t.Fatal("rules left out")51+ }52+}53+54+// Every rule 012 has goes out as Excel's and comes back the same.55+func TestXLSXRulesRoundTrip(t *testing.T) {56+ src := months(t)57+ lists, err := src.Book().AddSheet("Lists", 1)58+ if err != nil {59+ t.Fatal(err)60+ }61+ lists.Set(addr(t, "A1"), "North")62+ formats := []string{63+ `{"ranges":"B2:B5","condition":"gt","values":["100"],"fill":"green","bold":true}`,64+ `{"ranges":"B2:B5","condition":"between","values":["1","10"],"text":"red","fill":"yellow"}`,65+ `{"ranges":"C2:C5","condition":"contains","values":["o"],"italic":true}`,66+ `{"ranges":"C2:C5","condition":"not_contains","values":["x"],"text":"blue"}`,67+ `{"ranges":"C2:C5","condition":"starts_with","values":["re"],"underline":true}`,68+ `{"ranges":"C2:C5","condition":"ends_with","values":["s"],"strikethrough":true}`,69+ `{"ranges":"C2:C5","condition":"exactly","values":["Fees"],"fill":"magenta"}`,70+ `{"ranges":"A2:A5,C2:C5","condition":"empty","fill":"cyan"}`,71+ `{"ranges":"A2:A5","condition":"not_empty","text":"magenta"}`,72+ `{"ranges":"A2:A5","condition":"date_is","values":["today"],"fill":"red"}`,73+ `{"ranges":"A2:A5","condition":"date_before","values":["2026-09-30"],"fill":"red"}`,74+ `{"ranges":"A2:A5","condition":"date_after","values":["yesterday"],"fill":"blue"}`,75+ `{"ranges":"B2:B5","condition":"eq","values":["=B$2"],"fill":"blue"}`,76+ `{"ranges":"A2:C5","condition":"formula","values":["=$B2>=Lists!$B$1"],"text":"green"}`,77+ `{"ranges":"B2:B5","scale":[{"type":"min","color":"red"},{"type":"percentile","value":"50","color":"yellow"},{"type":"max","color":"green"}]}`,78+ `{"ranges":"B2:B5","scale":[{"type":"num","value":"0","color":"cyan"},{"type":"percent","value":"90","color":"blue"}]}`,79+ }80+ validations := []string{81+ `{"ranges":"D2:D5","criteria":"list","items":["Yes","No"],"reject":true,"help":"Pick one"}`,82+ `{"ranges":"E2:E5","criteria":"range","source":"Lists!A1:A5"}`,83+ `{"ranges":"F2:F5","criteria":"range","source":"A2:A5","reject":true}`,84+ `{"ranges":"G2:G5","criteria":"checkbox"}`,85+ `{"ranges":"H2:H5","criteria":"number","condition":"between","values":["1","10"],"reject":true}`,86+ `{"ranges":"I2:I5","criteria":"date","condition":"gt","values":["2026-01-31"]}`,87+ `{"ranges":"J2:J5","criteria":"date"}`,88+ `{"ranges":"K2:K5","criteria":"length","condition":"le","values":["=$B$2"]}`,89+ `{"ranges":"L2:L5","criteria":"formula","values":["=L2<>A2"]}`,90+ }91+ mustLoad(t, src, formats, validations)92+ formats, validations = formatLines(src), validationLines(src)93+ name, got := exportImport(t, src)94+ if len(got.Notes) > 0 {95+ t.Errorf("notes %q", got.Notes)96+ }97+ back := got.Sheet.Book().Sheet(0)98+ if f := formatLines(back); !reflect.DeepEqual(f, formats) {99+ t.Errorf("conditional formats:\n%s\nwant\n%s", strings.Join(f, "\n"), strings.Join(formats, "\n"))100+ }101+ if v := validationLines(back); !reflect.DeepEqual(v, validations) {102+ t.Errorf("validations:\n%s\nwant\n%s", strings.Join(v, "\n"), strings.Join(validations, "\n"))103+ }104+ // excelize reads what Excel would.105+ x, err := excelize.OpenFile(name)106+ if err != nil {107+ t.Fatal(err)108+ }109+ defer x.Close()110+ // excelize keeps one rule per range, the last written there.111+ cfs, err := x.GetConditionalFormats("Sheet1")112+ if err != nil || len(cfs) != 5 { // by sqref: B2:B5, C2:C5, A2:A5 C2:C5, A2:A5, A2:C5113+ t.Errorf("excelize: %d ranges %v", len(cfs), err)114+ }115+ if r := cfs["B2:B5"]; len(r) != 1 || r[0].Type != "2_color_scale" || r[0].MinType != "num" || r[0].MaxValue != "90" {116+ t.Errorf("excelize B2:B5: %+v", r)117+ }118+ if r := cfs["C2:C5"]; len(r) != 1 || r[0].Type != "cell" || r[0].Criteria != "equal to" || r[0].Value != `"Fees"` {119+ t.Errorf("excelize C2:C5: %+v", r)120+ }121+ dvs, err := x.GetDataValidations("Sheet1")122+ if err != nil || len(dvs) != len(validations) {123+ t.Fatalf("excelize: %d validations %v", len(dvs), err)124+ }125+ if dv := dvs[0]; dv.Type != "list" || dv.Formula1 != `"Yes,No"` || dv.ErrorStyle == nil || *dv.ErrorStyle != "stop" || dv.Prompt == nil || *dv.Prompt != "Pick one" {126+ t.Errorf("excelize list: %+v", dv)127+ }128+ if dv := dvs[1]; dv.Formula1 != "Lists!$A$1:$A$5" {129+ t.Errorf("excelize range: %+v", dv)130+ }131+ // excelize reports colors only for files with a theme part.132+ style, err := x.GetConditionalStyle(0)133+ if err != nil || style.Font == nil || !style.Font.Bold || style.Fill.Pattern != 1 {134+ t.Errorf("excelize dxf 0: %+v %v", style, err)135+ }136+}137+138+// Rules only Excel has, or ones 012 can't write, are left out with a139+// note.140+func TestXLSXRulesLimits(t *testing.T) {141+ s := months(t)142+ mustLoad(t, s, []string{143+ `{"ranges":"A2:A5","condition":"formula","values":["=B2>1#AND#B2<9"],"fill":"red"}`,144+ }, []string{145+ `{"ranges":"D2:D5","criteria":"list","items":["a,b","c"]}`,146+ })147+ _, got := exportImport(t, s)148+ want := []string{149+ "1 conditional format with no Excel equivalent left out, e.g. A2",150+ "1 data validation rule Excel can't hold left out, e.g. D2 (list items with commas or quotes, or over 255 characters, or a formula with no Excel equivalent)",151+ }152+ if res, err := Export(context.Background(), t.TempDir()+"/x.xlsx", XLSX, SnapBook(s), ExportOptions{}); err != nil || !reflect.DeepEqual(res.Notes, want) {153+ t.Errorf("export notes %q %v", res.Notes, err)154+ }155+ if len(got.Sheet.CondFormats())+len(got.Sheet.Validations()) != 0 {156+ t.Error("rules came back")157+ }158+}159+160+// What Excel writes: theme and indexed colors, rules 012 doesn't have,161+// validation in the 2010 extension.162+func TestXLSXRulesImport(t *testing.T) {163+ p := oneSheetParts("", `<row r="1"><c r="A1"><v>5</v></c></row>`)164+ p["xl/styles.xml"] = `<styleSheet ` + mainNS + `><cellXfs count="1"><xf numFmtId="0" fontId="0"/></cellXfs><dxfs count="3">` +165+ `<dxf><font><color rgb="FF9C0006"/></font><fill><patternFill><bgColor rgb="FFFFC7CE"/></patternFill></fill></dxf>` +166+ `<dxf><font><b/><color theme="4"/></font></dxf>` +167+ `<dxf><numFmt numFmtId="164" formatCode="0.0"/><border><left style="thin"/></border></dxf></dxfs></styleSheet>`168+ p["xl/worksheets/sheet1.xml"] = `<worksheet ` + mainNS + ` xmlns:x14="http://schemas.microsoft.com/office/spreadsheetml/2009/9/main" xmlns:xm="http://schemas.microsoft.com/office/excel/2006/main">` +169+ `<sheetData><row r="1"><c r="A1"><v>5</v></c></row></sheetData>` +170+ `<conditionalFormatting sqref="A1:A10"><cfRule type="dataBar" priority="4"><dataBar><cfvo type="min"/><cfvo type="max"/><color rgb="FF638EC6"/></dataBar></cfRule>` +171+ `<cfRule type="cellIs" dxfId="0" priority="2" operator="lessThan"><formula>10</formula></cfRule>` +172+ `<cfRule type="iconSet" priority="5"><iconSet><cfvo type="percent" val="0"/></iconSet></cfRule></conditionalFormatting>` +173+ `<conditionalFormatting sqref="B1:B10 D1"><cfRule type="expression" dxfId="1" priority="1"><formula>INT(B1)&lt;DATE(2026,1,5)</formula></cfRule>` +174+ `<cfRule type="expression" dxfId="2" priority="3"><formula>B1&gt;0</formula></cfRule>` +175+ `<cfRule type="colorScale" priority="6"><colorScale><cfvo type="min"/><cfvo type="max"/><color theme="0"/><color rgb="FF57BB8A"/></colorScale></cfRule>` +176+ `<cfRule type="timePeriod" dxfId="0" priority="7" timePeriod="lastWeek"><formula>x</formula></cfRule></conditionalFormatting>` +177+ `<dataValidations count="4"><dataValidation type="list" allowBlank="1" showErrorMessage="1" sqref="C1:C9"><formula1>"Yes,No"</formula1></dataValidation>` +178+ `<dataValidation type="whole" operator="greaterThanOrEqual" errorStyle="warning" showErrorMessage="1" error="Positive please" sqref="E1:E9"><formula1>0</formula1></dataValidation>` +179+ `<dataValidation type="time" sqref="F1"><formula1>0.5</formula1></dataValidation>` +180+ `<dataValidation type="list" sqref="G1"><formula1>Choices</formula1></dataValidation></dataValidations>` +181+ `<extLst><ext uri="{CCE6A557-97BC-4b89-ADB6-D9C93CAAB3DF}"><x14:dataValidations count="1"><x14:dataValidation type="list" allowBlank="1" showErrorMessage="1">` +182+ `<x14:formula1><xm:f>Lists!$A$1:$A$5</xm:f></x14:formula1><xm:sqref>H1:H9</xm:sqref></x14:dataValidation></x14:dataValidations></ext>` +183+ `<ext uri="{78C0D931-6437-407d-A8EE-F0AAD7539E65}"><x14:conditionalFormattings><x14:conditionalFormatting><x14:cfRule type="dataBar" id="{1}"/><xm:sqref>A1:A10</xm:sqref></x14:conditionalFormatting></x14:conditionalFormattings></ext></extLst></worksheet>`184+ path := writeParts(t, t.TempDir(), "excel.xlsx", p)185+ got, err := Import(context.Background(), path, Options{})186+ if err != nil {187+ t.Fatal(err)188+ }189+ s := got.Sheet190+ wantF := []string{191+ `{"ranges":"B1:B10,D1","condition":"date_before","values":["2026-01-05"],"text":"blue","bold":true}`,192+ `{"ranges":"A1:A10","condition":"lt","values":["10"],"text":"red","fill":"red"}`,193+ `{"ranges":"B1:B10,D1","scale":[{"type":"min","color":"yellow"},{"type":"max","color":"green"}]}`,194+ }195+ if f := formatLines(s); !reflect.DeepEqual(f, wantF) {196+ t.Errorf("conditional formats:\n%s\nwant\n%s", strings.Join(f, "\n"), strings.Join(wantF, "\n"))197+ }198+ wantV := []string{199+ `{"ranges":"C1:C9","criteria":"list","items":["Yes","No"],"reject":true}`,200+ `{"ranges":"E1:E9","criteria":"number","condition":"ge","values":["0"],"help":"Positive please"}`,201+ `{"ranges":"H1:H9","criteria":"range","source":"Lists!A1:A5","reject":true}`,202+ }203+ if v := validationLines(s); !reflect.DeepEqual(v, wantV) {204+ t.Errorf("validations:\n%s\nwant\n%s", strings.Join(v, "\n"), strings.Join(wantV, "\n"))205+ }206+ wantNotes := []string{207+ "5 conditional formats left out: data bars (1), dates in periods other than today, tomorrow and yesterday (1), icon sets (1), in Excel 2010's extension (data bars, icon sets) (1), without a text color, fill or text style 012 draws (1)",208+ "2 data validation rules left out: lists from named ranges or formulas (1), time rules (1)",209+ }210+ if !reflect.DeepEqual(got.Notes, wantNotes) {211+ t.Errorf("notes:\n%q\nwant\n%q", got.Notes, wantNotes)212+ }213+}
A internal/fileio/xlsxrulesimport.go +301 -0
@@ -0,0 +1,301 @@
1+package fileio2+3+import (4+ "cmp"5+ "fmt"6+ "maps"7+ "regexp"8+ "slices"9+ "strconv"10+ "strings"11+12+ "github.com/FelineStateMachine/012/internal/numfmt"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// ruleSkips counts the rules an import left out, by why.17+type ruleSkips struct {18+ formats, validations map[string]int19+}20+21+func (k *ruleSkips) skip(format bool, why string) {22+ m := &k.validations23+ if format {24+ m = &k.formats25+ }26+ if *m == nil {27+ *m = map[string]int{}28+ }29+ (*m)[why]++30+}31+32+// notes says what was left out, e.g. "3 conditional formats left out:33+// data bars (2), icon sets (1)".34+func (k *ruleSkips) notes() []string {35+ var out []string36+ for _, part := range []struct {37+ m map[string]int38+ one, many string39+ }{{k.formats, "conditional format", "conditional formats"}, {k.validations, "data validation rule", "data validation rules"}} {40+ n := 041+ var whys []string42+ for _, why := range slices.Sorted(maps.Keys(part.m)) {43+ n += part.m[why]44+ whys = append(whys, fmt.Sprintf("%s (%d)", why, part.m[why]))45+ }46+ if n > 0 {47+ out = append(out, count(n, part.one, part.many)+" left out: "+strings.Join(whys, ", "))48+ }49+ }50+ return out51+}52+53+// loadRules puts the rules read from a sheet on s, counting those left54+// out.55+func (bk *xlsxBook) loadRules(s *sheet.Sheet, r *xlsxSheetReader) {56+ cfs := slices.Clone(r.cfs)57+ slices.SortStableFunc(cfs, func(a, b xlsxCF) int { return cmp.Compare(a.priority, b.priority) })58+ var fs []sheet.CondFormat59+ for _, c := range cfs {60+ f, why := bk.condFormat(c)61+ if why != "" {62+ bk.skips.skip(true, why)63+ continue64+ }65+ fs = append(fs, f)66+ }67+ for range s.LoadCondFormats(fs) {68+ bk.skips.skip(true, "without a text color, fill or text style 012 draws")69+ }70+ var vs []sheet.Validation71+ for _, d := range r.dvs {72+ v, why := validationOf(d)73+ switch {74+ case why == "any":75+ case why != "":76+ bk.skips.skip(false, why)77+ default:78+ vs = append(vs, v)79+ }80+ }81+ for range s.LoadValidations(vs) {82+ bk.skips.skip(false, "that 012 can't check")83+ }84+ for range r.extRules {85+ bk.skips.skip(true, "in Excel 2010's extension (data bars, icon sets)")86+ }87+}88+89+// sqrefRanges reads an sqref, "A1:A9 C1".90+func sqrefRanges(ref string) ([]sheet.Rect, bool) {91+ var out []sheet.Rect92+ for _, f := range strings.Fields(ref) {93+ r, ok := sheet.ParseRange(strings.ReplaceAll(f, "$", ""))94+ if !ok {95+ return nil, false96+ }97+ out = append(out, r)98+ }99+ return out, len(out) > 0100+}101+102+// cfOps are Excel's comparisons by name.103+var cfOps = func() map[string]sheet.RuleOp {104+ m := map[string]sheet.RuleOp{}105+ for op, name := range cellIsOps {106+ m[name] = op107+ }108+ return m109+}()110+111+// unsupported names the conditional formats 012 has no rule for.112+var unsupported = map[string]string{"dataBar": "data bars", "iconSet": "icon sets", "top10": "top or bottom values",113+ "aboveAverage": "above or below average", "duplicateValues": "duplicate values", "uniqueValues": "unique values",114+ "containsErrors": "errors", "notContainsErrors": "errors"}115+116+// condFormat is an Excel rule as 012's, or why it was left out.117+func (bk *xlsxBook) condFormat(c xlsxCF) (sheet.CondFormat, string) {118+ rs, ok := sqrefRanges(c.sqref)119+ if !ok {120+ return sheet.CondFormat{}, "ranges 012 can't read"121+ }122+ f := sheet.CondFormat{Ranges: rs}123+ if c.dxf >= 0 && c.dxf < len(bk.styles.dxfs) {124+ f.Style = bk.styles.dxfs[c.dxf]125+ }126+ first := func() string {127+ if len(c.formulas) == 0 {128+ return ""129+ }130+ return c.formulas[0]131+ }132+ switch c.typ {133+ case "cellIs":134+ return cellIsRule(f, c)135+ case "containsText", "notContainsText", "beginsWith", "endsWith":136+ f.Op = map[string]sheet.RuleOp{"containsText": sheet.RuleContains, "notContainsText": sheet.RuleNotContains,137+ "beginsWith": sheet.RuleStartsWith, "endsWith": sheet.RuleEndsWith}[c.typ]138+ f.Args[0] = c.text139+ case "containsBlanks":140+ f.Op = sheet.RuleEmpty141+ case "notContainsBlanks":142+ f.Op = sheet.RuleNotEmpty143+ case "timePeriod":144+ if c.period != "today" && c.period != "tomorrow" && c.period != "yesterday" {145+ return f, "dates in periods other than today, tomorrow and yesterday"146+ }147+ f.Op, f.Args[0] = sheet.RuleDateIs, c.period148+ case "expression":149+ if op, day, ok := dateExpression(first()); ok {150+ f.Op, f.Args[0] = op, day151+ break152+ }153+ f.Op, f.Args[0] = sheet.RuleFormula, fromExcelFormula(first())154+ case "colorScale":155+ return colorScaleRule(f, c)156+ default:157+ if why, ok := unsupported[c.typ]; ok {158+ return f, why159+ }160+ return f, c.typ + " rules"161+ }162+ return f, ""163+}164+165+// cellIsRule reads a comparison: equal to text is "Text is exactly".166+func cellIsRule(f sheet.CondFormat, c xlsxCF) (sheet.CondFormat, string) {167+ op, ok := cfOps[c.op]168+ if !ok || len(c.formulas) < op.Args() {169+ return f, "comparisons 012 can't read"170+ }171+ f.Op = op172+ for k := range op.Args() {173+ f.Args[k] = fromExcelArg(c.formulas[k], false)174+ }175+ if text, quoted := unquoteExcel(c.formulas[0]); quoted && op == sheet.RuleEqual {176+ f.Op, f.Args[0] = sheet.RuleExactly, text177+ }178+ return f, ""179+}180+181+// colorScaleRule reads a color scale's points and colors.182+func colorScaleRule(f sheet.CondFormat, c xlsxCF) (sheet.CondFormat, string) {183+ if len(c.cfvo) < 2 || len(c.cfvo) != len(c.colors) {184+ return f, "color scales 012 can't read"185+ }186+ for k, v := range c.cfvo {187+ kind, ok := sheet.ParsePointKind(map[string]string{"min": "min", "max": "max", "num": "num",188+ "percent": "percent", "percentile": "percentile"}[v[0]])189+ if !ok {190+ return f, "color scale points set by formulas"191+ }192+ f.Scale = append(f.Scale, sheet.ScalePoint{Kind: kind, Value: v[1], Color: c.colors[k].scaleColor()})193+ }194+ return f, ""195+}196+197+// dateRE matches the expressions dates are written with: INT(A1)<DATE(2026,9,30)198+// or INT(A1)=TODAY()+1.199+var dateRE = regexp.MustCompile(`^INT\(\$?[A-Za-z]{1,3}\$?\d+\)([=<>])(?:DATE\((\d+),(\d+),(\d+)\)|TODAY\(\)([+-]1)?)$`)200+201+// dateExpression reads a date test written as xlsxrules.go writes one.202+func dateExpression(fx string) (sheet.RuleOp, string, bool) {203+ m := dateRE.FindStringSubmatch(strings.ReplaceAll(fx, " ", ""))204+ if m == nil {205+ return 0, "", false206+ }207+ op := map[string]sheet.RuleOp{"=": sheet.RuleDateIs, "<": sheet.RuleDateBefore, ">": sheet.RuleDateAfter}[m[1]]208+ if m[2] != "" {209+ y, _ := strconv.Atoi(m[2])210+ mo, _ := strconv.Atoi(m[3])211+ d, _ := strconv.Atoi(m[4])212+ return op, fmt.Sprintf("%d-%02d-%02d", y, mo, d), true213+ }214+ return op, map[string]string{"": "today", "+1": "tomorrow", "-1": "yesterday"}[m[5]], true215+}216+217+// unquoteExcel reads a formula that is only a string, "text".218+func unquoteExcel(fx string) (string, bool) {219+ fx = strings.TrimSpace(fx)220+ if len(fx) < 2 || fx[0] != '"' || fx[len(fx)-1] != '"' || strings.Contains(strings.ReplaceAll(fx[1:len(fx)-1], `""`, ""), `"`) {221+ return "", false222+ }223+ return strings.ReplaceAll(fx[1:len(fx)-1], `""`, `"`), true224+}225+226+// fromExcelArg reads a rule's value: text, a number (a date as a date,227+// for date rules) or a formula.228+func fromExcelArg(fx string, date bool) string {229+ if text, ok := unquoteExcel(fx); ok {230+ return text231+ }232+ n, err := strconv.ParseFloat(strings.TrimSpace(fx), 64)233+ switch {234+ case err != nil:235+ return fromExcelFormula(fx)236+ case date:237+ y, m, d := numfmt.Civil(int64(fromExcelSerial(n, sheet.Format{Kind: sheet.FmtDate})))238+ return fmt.Sprintf("%d-%02d-%02d", y, m, d)239+ }240+ return strings.TrimSpace(fx)241+}242+243+// validKinds are Excel's validation types as 012's kinds.244+var validKinds = map[string]sheet.ValidKind{"whole": sheet.ValidNumber, "decimal": sheet.ValidNumber,245+ "date": sheet.ValidDate, "textLength": sheet.ValidLength, "custom": sheet.ValidFormula}246+247+// validationOf is an Excel rule as 012's, or why it was left out ("any"248+// for a rule that allows anything, which has nothing to keep).249+func validationOf(d xlsxDV) (sheet.Validation, string) {250+ rs, ok := sqrefRanges(d.sqref)251+ if !ok {252+ return sheet.Validation{}, "ranges 012 can't read"253+ }254+ v := sheet.Validation{Ranges: rs, Reject: d.showErr && d.style == "stop", Help: cmp.Or(d.prompt, d.err)}255+ switch k, ok := validKinds[d.typ]; {256+ case d.typ == "list":257+ return listRule(v, d.formulas[0])258+ case d.typ == "none" || d.typ == "":259+ return v, "any"260+ case !ok:261+ return v, d.typ + " rules"262+ case k == sheet.ValidFormula:263+ v.Kind, v.Args[0] = k, fromExcelFormula(d.formulas[0])264+ default:265+ op, ok := cfOps[d.op]266+ if !ok {267+ return v, "comparisons 012 can't read"268+ }269+ v.Kind, v.Op = k, op270+ if k == sheet.ValidDate && op == sheet.RuleGreater && strings.TrimSpace(d.formulas[0]) == "0" {271+ v.Op = sheet.RuleNone // any date272+ break273+ }274+ for i := range op.Args() {275+ v.Args[i] = fromExcelArg(d.formulas[i], k == sheet.ValidDate)276+ }277+ }278+ return v, ""279+}280+281+// listRule reads a list: items in quotes, or a range; TRUE and FALSE282+// alone are a checkbox.283+func listRule(v sheet.Validation, fx string) (sheet.Validation, string) {284+ if text, ok := unquoteExcel(fx); ok {285+ for _, it := range strings.Split(text, ",") {286+ if it = strings.TrimSpace(it); it != "" {287+ v.Items = append(v.Items, it)288+ }289+ }290+ v.Kind = sheet.ValidList291+ if len(v.Items) == 2 && strings.EqualFold(v.Items[0], "TRUE") && strings.EqualFold(v.Items[1], "FALSE") {292+ v.Kind, v.Items = sheet.ValidCheckbox, nil293+ }294+ return v, ""295+ }296+ v.Kind, v.Source = sheet.ValidRange, strings.ReplaceAll(strings.TrimPrefix(strings.TrimSpace(fx), "="), "$", "")297+ if _, _, ok := v.SourceRange(); !ok {298+ return v, "lists from named ranges or formulas"299+ }300+ return v, ""301+}
A internal/fileio/xlsxrulesread.go +297 -0
@@ -0,0 +1,297 @@
1+package fileio2+3+import (4+ "encoding/xml"5+ "math"6+ "strconv"7+ "strings"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Reading a sheet's conditional formatting and data validation (see13+// xlsxrules.go for how they map): the elements after the rows, and the14+// same rules in Excel 2010's extension (x14:dataValidation, used for15+// lists from other sheets). Colors come in as the nearest named color.16+17+// xlsxColor is a color as SpreadsheetML gives it: ARGB, a theme color,18+// or an index into the legacy palette.19+type xlsxColor struct {20+ rgb string21+ theme int22+ indexed int23+}24+25+func readColor(se xml.StartElement) xlsxColor {26+ c := xlsxColor{theme: intAttr(se, "theme", -1), indexed: intAttr(se, "indexed", -1)}27+ c.rgb, _ = attr(se, "rgb")28+ return c29+}30+31+// officeTheme is the default Office theme's colors by index, and32+// legacyPalette the first entries of the indexed palette.33+var (34+ officeTheme = []uint32{0xFFFFFF, 0x000000, 0xE7E6E6, 0x44546A, 0x4472C4, 0xED7D31, 0xA5A5A5, 0xFFC000, 0x5B9BD5, 0x70AD47}35+ legacyPalette = []uint32{0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF,36+ 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF,37+ 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080}38+)39+40+// value is the color's RGB, if it has one 012 knows.41+func (c xlsxColor) value() (uint32, bool) {42+ switch {43+ case len(c.rgb) >= 6:44+ v, err := strconv.ParseUint(c.rgb[len(c.rgb)-6:], 16, 32)45+ return uint32(v), err == nil46+ case c.theme >= 0 && c.theme < len(officeTheme):47+ return officeTheme[c.theme], true48+ case c.indexed >= 0 && c.indexed < len(legacyPalette):49+ return legacyPalette[c.indexed], true50+ }51+ return 0, false52+}53+54+// named is the named color nearest c: one of 012's own colors exactly,55+// else by hue, where grays (and a color 012 can't read) are none.56+func (c xlsxColor) named() sheet.Color {57+ v, ok := c.value()58+ if !ok {59+ return sheet.ColorNone60+ }61+ hex := strings.ToUpper(strconv.FormatUint(uint64(v)|0xFF000000, 16))62+ for i, rgb := range ruleRGB {63+ for _, own := range rgb {64+ if own == hex {65+ return sheet.Color(i)66+ }67+ }68+ }69+ return hueColor(v)70+}71+72+// hueColor is the named color of v's hue, or none for grays.73+func hueColor(v uint32) sheet.Color {74+ r, g, b := float64(v>>16&0xFF), float64(v>>8&0xFF), float64(v&0xFF)75+ hi, lo := max(r, g, b), min(r, g, b)76+ if hi == 0 || (hi-lo)/hi < 0.05 {77+ return sheet.ColorNone78+ }79+ var h float6480+ switch d := hi - lo; hi {81+ case r:82+ h = math.Mod((g-b)/d, 6)83+ case g:84+ h = (b-r)/d + 285+ default:86+ h = (r-g)/d + 487+ }88+ h = math.Mod(h*60+360, 360)89+ switch {90+ case h < 20 || h >= 330:91+ return sheet.ColorRed92+ case h < 70:93+ return sheet.ColorYellow94+ case h < 165:95+ return sheet.ColorGreen96+ case h < 200:97+ return sheet.ColorCyan98+ case h < 260:99+ return sheet.ColorBlue100+ }101+ return sheet.ColorMagenta102+}103+104+// scaleColor is the named color nearest c for a color scale, whose105+// points need one: grays, white included, take the scale color nearest106+// them.107+func (c xlsxColor) scaleColor() sheet.Color {108+ if n := c.named(); n != sheet.ColorNone {109+ return n110+ }111+ v, _ := c.value()112+ best, dist := sheet.ColorYellow, math.Inf(1)113+ for i := 1; i < sheet.NumColors; i++ {114+ s, _ := strconv.ParseUint(ruleRGB[i][2][2:], 16, 32)115+ dr, dg, db := float64(v>>16&0xFF)-float64(s>>16&0xFF), float64(v>>8&0xFF)-float64(s>>8&0xFF), float64(v&0xFF)-float64(s&0xFF)116+ if d := dr*dr + dg*dg + db*db; d < dist {117+ best, dist = sheet.Color(i), d118+ }119+ }120+ return best121+}122+123+// readDxf reads the rest of a <dxf>: its font's styles and color, and124+// its fill's color (a dxf's solid fill is its pattern's background).125+func readDxf(x *xmlStream) (sheet.RuleStyle, error) {126+ var st sheet.RuleStyle127+ var fg, bg xlsxColor128+ in := ""129+ for depth := x.depth; ; {130+ t, err := x.next()131+ if err != nil {132+ return st, eofAsUnexpected(err)133+ }134+ switch t := t.(type) {135+ case xml.StartElement:136+ if x.depth == depth+1 {137+ in = t.Name.Local138+ }139+ readDxfPart(t, in, &st, &fg, &bg)140+ case xml.EndElement:141+ if x.depth < depth {142+ st.Fill = bg.named()143+ if st.Fill == sheet.ColorNone {144+ st.Fill = fg.named()145+ }146+ return st, nil147+ }148+ }149+ }150+}151+152+// readDxfPart reads an element of a dxf's font or fill.153+func readDxfPart(t xml.StartElement, in string, st *sheet.RuleStyle, fg, bg *xlsxColor) {154+ switch {155+ case in == "font" && t.Name.Local == "b":156+ st.Bold = boolAttr(t, "val", true)157+ case in == "font" && t.Name.Local == "i":158+ st.Italic = boolAttr(t, "val", true)159+ case in == "font" && t.Name.Local == "strike":160+ st.Strikethrough = boolAttr(t, "val", true)161+ case in == "font" && t.Name.Local == "u":162+ st.Underline = attrOr(t, "val", "single") != "none"163+ case in == "font" && t.Name.Local == "color":164+ st.Text = readColor(t).named()165+ case in == "fill" && t.Name.Local == "bgColor":166+ *bg = readColor(t)167+ case in == "fill" && t.Name.Local == "fgColor":168+ *fg = readColor(t)169+ }170+}171+172+// xlsxCF is a <cfRule>, with the sqref of its <conditionalFormatting>.173+type xlsxCF struct {174+ sqref string175+ typ, op, text, period string176+ dxf, priority int177+ formulas []string178+ cfvo [][2]string // type and val179+ colors []xlsxColor180+}181+182+// xlsxDV is a <dataValidation>, of the main part or the extension.183+type xlsxDV struct {184+ typ, op, style, prompt, err, sqref string185+ showErr bool186+ formulas [2]string187+}188+189+// maxRules caps the rules read from one sheet.190+const maxRules = 10000191+192+// readRuleElement reads a <conditionalFormatting> or <dataValidation>193+// (of the main part or the extension) just started, at any depth, into194+// the reader's rules; it reports whether se was one.195+func (r *xlsxSheetReader) readRuleElement(se xml.StartElement) (bool, error) {196+ switch {197+ case len(r.cfs)+len(r.dvs) >= maxRules:198+ return false, nil199+ case se.Name.Local == "conditionalFormatting" && r.x.depth == 2:200+ return true, r.readCondFormatting(se)201+ case se.Name.Local == "dataValidation":202+ return true, r.readValidation(se)203+ case se.Name.Local == "conditionalFormatting":204+ r.extRules++ // Excel 2010's data bars, icon sets and the like205+ return true, r.x.skip()206+ }207+ return false, nil208+}209+210+func (r *xlsxSheetReader) readCondFormatting(se xml.StartElement) error {211+ ref, _ := attr(se, "sqref")212+ var cur *xlsxCF213+ for depth := r.x.depth; ; {214+ t, err := r.x.next()215+ if err != nil {216+ return eofAsUnexpected(err)217+ }218+ switch t := t.(type) {219+ case xml.EndElement:220+ if r.x.depth < depth {221+ return nil222+ }223+ case xml.StartElement:224+ if t.Name.Local == "cfRule" && len(r.cfs) < maxRules {225+ r.cfs = append(r.cfs, xlsxCF{sqref: ref, typ: attrOr(t, "type", ""), op: attrOr(t, "operator", ""),226+ text: attrOr(t, "text", ""), period: attrOr(t, "timePeriod", ""), dxf: intAttr(t, "dxfId", -1),227+ priority: intAttr(t, "priority", len(r.cfs)+1)})228+ cur = &r.cfs[len(r.cfs)-1]229+ } else if err := r.cfPart(t, cur); err != nil {230+ return err231+ }232+ }233+ }234+}235+236+// cfPart reads an element inside a <cfRule>: a formula, or a color237+// scale's point or color. Each rule keeps at most three of each.238+func (r *xlsxSheetReader) cfPart(t xml.StartElement, cur *xlsxCF) error {239+ switch t.Name.Local {240+ case "formula":241+ var err error242+ if r.buf, err = r.x.text(r.buf[:0]); err != nil {243+ return err244+ }245+ if cur != nil && len(cur.formulas) < 3 {246+ cur.formulas = append(cur.formulas, string(r.buf))247+ }248+ case "cfvo":249+ if cur != nil && len(cur.cfvo) < 3 {250+ cur.cfvo = append(cur.cfvo, [2]string{attrOr(t, "type", ""), attrOr(t, "val", "")})251+ }252+ case "color":253+ if cur != nil && len(cur.colors) < 3 {254+ cur.colors = append(cur.colors, readColor(t))255+ }256+ }257+ return nil258+}259+260+func (r *xlsxSheetReader) readValidation(se xml.StartElement) error {261+ dv := xlsxDV{typ: attrOr(se, "type", "none"), op: attrOr(se, "operator", "between"), style: attrOr(se, "errorStyle", "stop"),262+ prompt: attrOr(se, "prompt", ""), err: attrOr(se, "error", ""), sqref: attrOr(se, "sqref", ""),263+ showErr: boolAttr(se, "showErrorMessage", false)}264+ for depth := r.x.depth; ; {265+ t, err := r.x.next()266+ if err != nil {267+ return eofAsUnexpected(err)268+ }269+ switch t := t.(type) {270+ case xml.EndElement:271+ if r.x.depth < depth {272+ r.dvs = append(r.dvs, dv)273+ return nil274+ }275+ case xml.StartElement:276+ k := -1277+ switch t.Name.Local {278+ case "formula1":279+ k = 0280+ case "formula2":281+ k = 1282+ case "sqref":283+ k = 2284+ default:285+ continue286+ }287+ if r.buf, err = r.x.text(r.buf[:0]); err != nil {288+ return err289+ }290+ if k == 2 {291+ dv.sqref = string(r.buf)292+ } else {293+ dv.formulas[k] = string(r.buf)294+ }295+ }296+ }297+}
M internal/fileio/xlsxsheet.go +6 -0
M internal/fileio/xlsxstyles.go +12 -1
M internal/fileio/xlsxviewread.go +10 -2
M internal/fileio/xlsxwrite.go +46 -1
@@ -32,6 +32,7 @@ type xlsxWriter struct {
3232 missingSheet string // the sheet missing's example names3333 known map[string]bool // keys of the sheets written3434 renamed map[string]string // the names of sheets written under another, by key35+ rules ruleNotes // rules left out, see xlsxrules.go3536 }3637 3738 // valueCount counts formulas written as values, keeping the first one's
@@ -98,6 +99,7 @@ func (w *xlsxWriter) sheet(bw *bufio.Writer, ws string, snap *Snapshot, active b
9899 w.styledRows(bw, snap, styled, sheet.MaxRows)99100 bw.WriteString(`</sheetData>`)100101 writeAutoFilter(bw, snap)102+ w.writeRules(bw, ws, snap)101103 bw.WriteString(`</worksheet>`)102104 return r.To.Row - r.From.Row + 1103105 }
@@ -268,6 +270,49 @@ type xlsxStyleTable struct {
268270 xfs []xlsxStyle // index 0 is the default269271 codes []string // custom number formats, from id 164270272 fonts []sheet.Style273+ dxfs []sheet.RuleStyle // conditional formats' styles, see xlsxrules.go274+}275+276+// dxf is the index of the differential style for a rule's style.277+func (t *xlsxStyleTable) dxf(st sheet.RuleStyle) int {278+ if i := slices.Index(t.dxfs, st); i >= 0 {279+ return i280+ }281+ t.dxfs = append(t.dxfs, st)282+ return len(t.dxfs) - 1283+}284+285+// dxfsXML writes the differential styles: a font with the text styles286+// and color, and a solid fill. Excel reads a dxf's fill from the287+// pattern's background color and some readers from its foreground, so288+// both are the fill.289+func (t *xlsxStyleTable) dxfsXML() string {290+ if len(t.dxfs) == 0 {291+ return ""292+ }293+ var b strings.Builder294+ fmt.Fprintf(&b, `<dxfs count="%d">`, len(t.dxfs))295+ for _, st := range t.dxfs {296+ b.WriteString(`<dxf><font>`)297+ for _, on := range []struct {298+ set bool299+ tag string300+ }{{st.Bold, `<b/>`}, {st.Italic, `<i/>`}, {st.Strikethrough, `<strike/>`}, {st.Underline, `<u/>`}} {301+ if on.set {302+ b.WriteString(on.tag)303+ }304+ }305+ if st.Text != sheet.ColorNone {306+ fmt.Fprintf(&b, `<color rgb="%s"/>`, ruleRGB[st.Text][0])307+ }308+ b.WriteString(`</font>`)309+ if st.Fill != sheet.ColorNone {310+ c := ruleRGB[st.Fill][1]311+ fmt.Fprintf(&b, `<fill><patternFill patternType="solid"><fgColor rgb="%s"/><bgColor rgb="%s"/></patternFill></fill>`, c, c)312+ }313+ b.WriteString(`</dxf>`)314+ }315+ return b.String() + `</dxfs>`271316 }272317 273318 func newXLSXStyleTable() *xlsxStyleTable {
@@ -344,7 +389,7 @@ func (t *xlsxStyleTable) xml() string {
344389 `<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>` +345390 `<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>`)346391 fmt.Fprintf(&b, `<cellXfs count="%d">%s</cellXfs>`, len(t.xfs), xfs.String())347- b.WriteString(`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`)392+ b.WriteString(`<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>` + t.dxfsXML() + `</styleSheet>`)348393 return b.String()349394 }350395
M internal/sheet/validation.go +4 -4
M internal/ui/graphics.go +17 -11
@@ -49,10 +49,21 @@ func (t *terminal) wrap(seq string) string {
4949 return seq5050 }5151 52-// probes are the startup queries: kitty graphics support, and live53-// light and dark changes (mode 2031).52+// probes are the startup queries: kitty graphics support, live light53+// and dark changes (mode 2031), and the 16 palette colors (OSC 4), which54+// chart images and color scales draw with.5455 func (t *terminal) probes() tea.Cmd {55- return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark)56+ return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark + paletteQuery())57+}58+59+// paletteQuery asks for the 16 ANSI colors; terminals that don't answer60+// leave xtermColors in use.61+func paletteQuery() string {62+ var q strings.Builder63+ for i := range 16 {64+ fmt.Fprintf(&q, "\x1b]4;%d;?\x07", i)65+ }66+ return q.String()5667 }5768 5869 // firstImageID is the image id of the first chart. Placeholders name
@@ -69,14 +80,9 @@ func (t *terminal) handle(msg tea.Msg) tea.Cmd {
6980 case uv.KittyGraphicsEvent:7081 if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !t.kitty {7182 t.kitty = true72- // Ask for the cell size (for image proportions) and the73- // palette colors charts draw with.74- var q strings.Builder75- q.WriteString(ansi.WindowOp(16)) // 16: report the cell size in pixels76- for i := range 16 {77- fmt.Fprintf(&q, "\x1b]4;%d;?\x07", i)78- }79- return tea.Raw(q.String())83+ // Ask for the cell size, for image proportions; the palette84+ // was asked for at startup.85+ return tea.Raw(ansi.WindowOp(16)) // 16: report the cell size in pixels8086 }8187 case uv.CellSizeEvent:8288 if msg.Width > 0 && msg.Height > 0 {