oficina / sheets Public

feat: surface an imported fill and ink that fail AA, per spec 11.1's trigger

Spec 14's deferred table had one row whose trigger was "plan 2's grid
work". This is it firing.

It needed something the spec had assumed we already had: the authored
INK. readStyle retained fills and never font colours, so the pair 11.1
is about could not exist in our data at all — a fill on its own cannot
fail a contrast check, because the other half was always our own
theme's, which is a pairing this app controls. Format gains InkHex, and
XLSX import and export carry it.

InkHex participates in formatDigest. Formats are interned by that
digest, so a field left out of it makes two genuinely different formats
collide onto one row and the second cell silently adopts the first's
colour.

The export condition is `bold || italic || ink`, not `bold || italic`:
a cell whose only font property is a colour would otherwise get no Font
element and lose the retained ink on the way out — silently, and only
for exactly the cells 11.1 is about.

WCAG contrast is implemented here rather than borrowed: rastrillo has
it, but only inside ui/contrast_test.go, and a _test.go exports nothing
to another package. The test checks it against Excel's own failing
preset at the figure the spec records from an independent source
(#9C6500 on #FFEB9C, 4.12:1), so a disagreement means we are wrong, not
the spec.

Surfaced, never overridden. A notice names the cells; nothing changes a
colour. 11.1 ruled that correcting an imported file would lie about the
document.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RN4HhHR4rHH3Fxh4LMds5
Paul Campbell pushed by paul@keymail.dev b54989da87eabccbbb19d65f7e77b39abe9a697c parent cc1bbeb
13 files changed, +379 −10
  • docs/superpowers/specs/2026-08-30-sheets-design.md +9 −2
  • internal/sheets/contrast.go +121 −0
  • internal/sheets/contrast_test.go +172 −0
  • internal/sheets/handlers_sheets.go +13 −5
  • internal/sheets/handlers_table.go +2 −0
  • internal/sheets/migrations/0003_alter_formats.sql +2 −0
  • internal/sheets/migrations/schema.sql +1 −1
  • internal/sheets/store/handle_grid.go +6 −0
  • internal/sheets/store/models.go +12 −1
  • internal/sheets/templates/layout.html +20 −0
  • internal/sheets/templates/sheet.html +1 −0
  • internal/sheets/templates/table.html +1 −0
  • internal/sheets/xlsx/format.go +19 −1

CI passed — run details

diff --git a/docs/superpowers/specs/2026-08-30-sheets-design.md b/docs/superpowers/specs/2026-08-30-sheets-design.md
index 4f2cd24..261c2cf 100644
--- a/docs/superpowers/specs/2026-08-30-sheets-design.md
+++ b/docs/superpowers/specs/2026-08-30-sheets-design.md
@@ -883,7 +883,14 @@ is working exactly as specified.
**The obligation that follows is surfacing, not overriding** — telling a
person their imported file contains a pairing that fails, and leaving the
-decision to them. That is grid work and belongs to plan 2 (§14).
+decision to them. That was grid work and belonged to plan 2 (§14).
+
+**Built 2026-09-01.** It needed one thing this spec had assumed: the
+authored *ink*. `readStyle` retained fills and never font colours, so the
+pair §11.1 is about could not exist in our data at all — a fill alone
+cannot fail, since the other half was always our own theme's. `Format`
+gained `InkHex`, XLSX import and export carry it, and the grid and table
+views name the failing cells in a notice. No cell is altered.
*(Found 2026-08-31 by ui-guidelines-52 while building rastrillo's colour
engine, from Excel's documented preset fill/ink pairs. Related: `#0000FF`
@@ -1009,7 +1016,7 @@ it is a fallback rather than a scramble.
| `client` shape for anything but the grid | A surface genuinely needs client-held keys |
| Print / PDF export | Asked for. docs-e6 keeps its layout engine's page model generic over content boxes so this stays open: paginated grid, repeating header rows, scale-to-width, explicit page breaks, charts as vector |
| Live sheet range embedded in a doc | A known future crossing of two collaboration models |
-| **Surfacing an imported fill/ink pairing that fails WCAG AA** | The grid renders imported formatting. Trigger: plan 2's grid work — see §11.1 |
+| ~~Surfacing an imported fill/ink pairing that fails WCAG AA~~ | **Trigger fired and built, 2026-09-01.** Plan 2's grid work landed, and with it `Format.InkHex` (the authored font colour, which nothing retained before), a WCAG ratio, and a notice naming the failing cells on both the grid and the table view. Surfaced, never overridden — §11.1's ruling stands. |
---
diff --git a/internal/sheets/contrast.go b/internal/sheets/contrast.go
new file mode 100644
index 0000000..1741821
--- /dev/null
+++ b/internal/sheets/contrast.go
@@ -0,0 +1,121 @@
+package sheets
+
+import (
+ "math"
+ "strconv"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// aaFloor is WCAG 2.2's contrast minimum for normal-size text: 4.5:1.
+const aaFloor = 4.5
+
+// maxWarnedCells bounds the list a page names. Somebody importing a
+// heavily formatted file does not need four hundred cell references;
+// they need to know it happened and where to start.
+const maxWarnedCells = 12
+
+// contrastRatio is WCAG 2.x relative luminance, (L1+0.05)/(L2+0.05).
+//
+// Written here rather than borrowed: rastrillo has this computation,
+// but only inside ui/contrast_test.go, and a _test.go file exports
+// nothing to another package. Copying twenty lines of a published
+// standard beats vendoring a test file or waiting for an upstream
+// export — and the constants below are the standard's, checkable
+// against it rather than against us.
+func contrastRatio(a, b string) (float64, bool) {
+ la, ok := relativeLuminance(a)
+ if !ok {
+ return 0, false
+ }
+ lb, ok := relativeLuminance(b)
+ if !ok {
+ return 0, false
+ }
+ if la < lb {
+ la, lb = lb, la
+ }
+ return (la + 0.05) / (lb + 0.05), true
+}
+
+// relativeLuminance parses an 8-char ARGB or 6-char RGB hex and returns
+// WCAG relative luminance. The alpha channel is ignored: a fill in a
+// spreadsheet is composited over the sheet, and treating a translucent
+// fill as opaque overstates rather than understates the risk, which is
+// the safe direction for a warning.
+func relativeLuminance(hex string) (float64, bool) {
+ switch len(hex) {
+ case 8:
+ hex = hex[2:]
+ case 6:
+ default:
+ return 0, false
+ }
+ channel := func(s string) (float64, bool) {
+ v, err := strconv.ParseUint(s, 16, 8)
+ if err != nil {
+ return 0, false
+ }
+ c := float64(v) / 255
+ if c <= 0.04045 {
+ return c / 12.92, true
+ }
+ return math.Pow((c+0.055)/1.055, 2.4), true
+ }
+ r, okR := channel(hex[0:2])
+ g, okG := channel(hex[2:4])
+ b, okB := channel(hex[4:6])
+ if !okR || !okG || !okB {
+ return 0, false
+ }
+ return 0.2126*r + 0.7152*g + 0.0722*b, true
+}
+
+// lowContrast reports whether a format's AUTHORED pair fails AA.
+//
+// Both halves must be present. A fill on its own cannot fail: our own
+// ink comes from the theme, and that pairing is one this app controls
+// and gates elsewhere. What can fail is a fill and an ink that both
+// arrived in somebody's file — which is not hypothetical, since Excel
+// ships #9C6500 on #FFEB9C at 4.12:1 as a conditional-formatting
+// preset (spec §11.1).
+//
+// It reports, and nothing acts on it beyond telling a person. Spec
+// §11.1 ruled that we render an imported file as authored: silently
+// correcting it would lie about the document. The obligation is
+// surfacing, not overriding.
+func lowContrast(f *store.Format) bool {
+ if f == nil || f.FillHex == "" || f.InkHex == "" {
+ return false
+ }
+ ratio, ok := contrastRatio(f.FillHex, f.InkHex)
+ if !ok {
+ return false
+ }
+ return ratio < aaFloor
+}
+
+// warnedCells lists the A1 references in a window whose imported
+// colours fail AA, newest window first and capped.
+func warnedCells(h *store.Handle, idx posIndex, rows, cols int) []string {
+ coords, err := h.Cells()
+ if err != nil {
+ return nil
+ }
+ var refs []string
+ for _, c := range coords {
+ r, okR := idx.rowPos[c.RowID]
+ col, okC := idx.colPos[c.ColID]
+ if !okR || !okC || r >= rows || col >= cols {
+ continue
+ }
+ if !lowContrast(h.Format(c.RowID, c.ColID)) {
+ continue
+ }
+ if len(refs) == maxWarnedCells {
+ return append(refs, "…")
+ }
+ refs = append(refs, colLabel(col)+strconv.Itoa(r+1))
+ }
+ return refs
+}
diff --git a/internal/sheets/contrast_test.go b/internal/sheets/contrast_test.go
new file mode 100644
index 0000000..132a322
--- /dev/null
+++ b/internal/sheets/contrast_test.go
@@ -0,0 +1,172 @@
+package sheets
+
+import (
+ "fmt"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/sheets/internal/sheets/store"
+)
+
+// The pair spec §11.1 names: Excel's own light-yellow
+// conditional-formatting preset, #9C6500 ink on #FFEB9C fill, which the
+// spec records as 4.12:1 against an independent source. If our
+// implementation of a published standard disagrees with a figure
+// somebody else computed, ours is what is wrong.
+func TestExcelsOwnPresetIsBelowTheFloor(t *testing.T) {
+ got, ok := contrastRatio("FFFFEB9C", "FF9C6500")
+ if !ok {
+ t.Fatal("contrastRatio could not read the pair")
+ }
+ if math.Abs(got-4.12) > 0.02 {
+ t.Fatalf("contrast(#FFEB9C, #9C6500) = %.3f, want 4.12 — "+
+ "the figure spec §11.1 records against an independent source", got)
+ }
+ if got >= aaFloor {
+ t.Fatalf("%.2f is not below the %.1f floor; this pair is the whole reason §11.1 exists", got, aaFloor)
+ }
+}
+
+// Anchors from the standard itself, so the implementation is checked
+// against WCAG rather than against its own output.
+func TestContrastRatioAnchors(t *testing.T) {
+ for _, tc := range []struct {
+ a, b string
+ want float64
+ }{
+ {"FFFFFFFF", "FF000000", 21}, // white on black is the maximum
+ {"FF000000", "FFFFFFFF", 21}, // and the order must not matter
+ {"FF808080", "FF808080", 1}, // a colour against itself
+ {"FFFFFFFF", "FF767676", 4.54}, // the classic "just passes" grey
+ } {
+ got, ok := contrastRatio(tc.a, tc.b)
+ if !ok {
+ t.Fatalf("contrastRatio(%s, %s) failed to parse", tc.a, tc.b)
+ }
+ if math.Abs(got-tc.want) > 0.02 {
+ t.Errorf("contrast(%s, %s) = %.3f, want %.2f", tc.a, tc.b, got, tc.want)
+ }
+ }
+}
+
+func TestContrastRatioRejectsRubbish(t *testing.T) {
+ for _, bad := range []string{"", "fff", "zzzzzz", "FFFFFFFFFF"} {
+ if _, ok := contrastRatio(bad, "FF000000"); ok {
+ t.Errorf("contrastRatio accepted %q", bad)
+ }
+ }
+ // Six-char RGB is accepted as well as eight-char ARGB, because
+ // excelize hands back both shapes depending on the file.
+ if _, ok := contrastRatio("FFEB9C", "9C6500"); !ok {
+ t.Error("contrastRatio rejected plain 6-char RGB")
+ }
+}
+
+// A fill on its own cannot fail. Our own ink comes from the theme, and
+// that pairing is one this app controls; only a pair that both arrived
+// in somebody's file can fail here.
+func TestLowContrastNeedsBothHalves(t *testing.T) {
+ cases := []struct {
+ name string
+ f *store.Format
+ want bool
+ }{
+ {"nil format", nil, false},
+ {"no colours at all", &store.Format{}, false},
+ {"fill only", &store.Format{FillHex: "FFFFEB9C"}, false},
+ {"ink only", &store.Format{InkHex: "FF9C6500"}, false},
+ {"Excel's failing preset", &store.Format{FillHex: "FFFFEB9C", InkHex: "FF9C6500"}, true},
+ {"black on white", &store.Format{FillHex: "FFFFFFFF", InkHex: "FF000000"}, false},
+ {"unreadable", &store.Format{FillHex: "FF0000FF", InkHex: "FF000000"}, true},
+ }
+ for _, tc := range cases {
+ if got := lowContrast(tc.f); got != tc.want {
+ t.Errorf("%s: lowContrast = %v, want %v", tc.name, got, tc.want)
+ }
+ }
+}
+
+// The obligation is surfacing, not overriding: a person must be told,
+// and the cell must be left alone.
+func TestAFailingPairIsSurfacedOnBothViews(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Imported")
+
+ // Put a value in A1, then give it the format an import would have
+ // retained from Excel's own preset.
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"warned"}`)
+
+ h, err := app.openTabFor(s)
+ if err != nil {
+ t.Fatalf("open tab: %v", err)
+ }
+ rowID, _ := h.EnsureRow(0)
+ colID, _ := h.EnsureCol(0)
+ if err := h.SetFormat(rowID, colID, store.Format{
+ FillHex: "FFFFEB9C", InkHex: "FF9C6500",
+ }); err != nil {
+ t.Fatalf("SetFormat: %v", err)
+ }
+ h.Release()
+
+ for _, path := range []string{
+ fmt.Sprintf("/sheets/%d", s.ID),
+ fmt.Sprintf("/sheets/%d/table", s.ID),
+ } {
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet, path, nil), cookies)).Body.String()
+ if !strings.Contains(body, "below the contrast floor") {
+ t.Errorf("%s does not surface the failing pair", path)
+ }
+ if !strings.Contains(body, "<code>A1</code>") {
+ t.Errorf("%s does not name the cell", path)
+ }
+ }
+}
+
+// Surfacing must not fire on a sheet that has no imported colours at
+// all, or it becomes a banner everybody learns to ignore.
+func TestNoNoticeWithoutAFailingPair(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Plain")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"7"}`)
+
+ body := do(t, app, as(httptest.NewRequest(http.MethodGet,
+ fmt.Sprintf("/sheets/%d", s.ID), nil), cookies)).Body.String()
+ if strings.Contains(body, "below the contrast floor") {
+ t.Fatal("a sheet with no imported colours is warned about anyway")
+ }
+}
+
+// Spec §11.1: rendered as authored. The warning must not change what
+// the cell holds or how it is painted.
+func TestSurfacingDoesNotChangeTheCell(t *testing.T) {
+ app, cookies := signIn(t)
+ s, cells := newSheet(t, app, cookies, "Imported")
+ postCell(t, app, cookies, cells, `{"r":0,"c":0,"raw":"warned"}`)
+
+ h, err := app.openTabFor(s)
+ if err != nil {
+ t.Fatalf("open tab: %v", err)
+ }
+ rowID, _ := h.EnsureRow(0)
+ colID, _ := h.EnsureCol(0)
+ if err := h.SetFormat(rowID, colID, store.Format{FillHex: "FFFFEB9C", InkHex: "FF9C6500"}); err != nil {
+ t.Fatalf("SetFormat: %v", err)
+ }
+ h.Release()
+
+ do(t, app, as(httptest.NewRequest(http.MethodGet, fmt.Sprintf("/sheets/%d", s.ID), nil), cookies))
+
+ h2, err := app.openTabFor(s)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer h2.Release()
+ f := h2.Format(rowID, colID)
+ if f == nil || f.FillHex != "FFFFEB9C" || f.InkHex != "FF9C6500" {
+ t.Fatalf("the colours changed after being surfaced: %+v", f)
+ }
+}
diff --git a/internal/sheets/handlers_sheets.go b/internal/sheets/handlers_sheets.go
index 1bbb6c7..53bfa4c 100644
--- a/internal/sheets/handlers_sheets.go
+++ b/internal/sheets/handlers_sheets.go
@@ -23,6 +23,11 @@ type sheetPageView struct {
Sheet SheetSummary
Rows int
Cols int
+ // Warned lists cells whose IMPORTED fill and ink fail WCAG AA.
+ // Spec §11.1 ruled that such a pair is rendered as authored and
+ // that the obligation is surfacing, not overriding — so this is a
+ // notice on the page, never a correction to the cell.
+ Warned []string
}
// handleSheetList renders every sheet on the instance.
@@ -70,11 +75,14 @@ func (a *App) handleSheetPage(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- a.render(w, r, "sheet", s.Title+" — Oficina Sheets", http.StatusOK, sheetPageView{
- Sheet: s,
- Rows: defaultWindowRows,
- Cols: defaultWindowCols,
- })
+ view := sheetPageView{Sheet: s, Rows: defaultWindowRows, Cols: defaultWindowCols}
+ // Best effort: a sheet whose handle will not open still renders its
+ // page, and the grid's own fetch reports the failure properly.
+ if h, err := a.openTabFor(s); err == nil {
+ view.Warned = warnedCells(h, newPosIndex(h), defaultWindowRows, defaultWindowCols)
+ h.Release()
+ }
+ a.render(w, r, "sheet", s.Title+" — Oficina Sheets", http.StatusOK, view)
}
// sheetFromURL reads {sheetID} and loads it, answering 404 itself if it
diff --git a/internal/sheets/handlers_table.go b/internal/sheets/handlers_table.go
index 50acb52..a9c1595 100644
--- a/internal/sheets/handlers_table.go
+++ b/internal/sheets/handlers_table.go
@@ -43,6 +43,7 @@ type tableView struct {
Sheet SheetSummary
Rows []tableRow
Cols []string
+ Warned []string
Page int
Pages int
PrevPage int
@@ -110,6 +111,7 @@ func (a *App) handleTable(w http.ResponseWriter, r *http.Request) {
view.Rows = append(view.Rows, row)
}
+ view.Warned = warnedCells(h, idx, maxWindowRows, tableCols)
a.render(w, r, "table", s.Title+" — table view", http.StatusOK, view)
}
diff --git a/internal/sheets/migrations/0003_alter_formats.sql b/internal/sheets/migrations/0003_alter_formats.sql
new file mode 100644
index 0000000..a4b1156
--- /dev/null
+++ b/internal/sheets/migrations/0003_alter_formats.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `formats` ADD `ink_hex` text;
+
diff --git a/internal/sheets/migrations/schema.sql b/internal/sheets/migrations/schema.sql
index dd67e05..0a63a05 100644
--- a/internal/sheets/migrations/schema.sql
+++ b/internal/sheets/migrations/schema.sql
@@ -7,7 +7,7 @@ CREATE TABLE `cols` (`id` integer PRIMARY KEY AUTOINCREMENT,`tab_id` integer,`po
CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`kind` text,`tab_id` integer,`actor_id` integer,`payload_sealed` blob,`trigger_id` integer,`derived_count` integer,`created_at` datetime);
-CREATE TABLE `formats` (`id` integer PRIMARY KEY AUTOINCREMENT,`digest` text,`number_fmt` text,`bold` numeric,`italic` numeric,`fill_intent_h` real,`fill_intent_c` real,`fill_hex` text,`align` text);
+CREATE TABLE `formats` (`id` integer PRIMARY KEY AUTOINCREMENT,`digest` text,`number_fmt` text,`bold` numeric,`italic` numeric,`fill_intent_h` real,`fill_intent_c` real,`fill_hex` text,`align` text, `ink_hex` text);
CREATE TABLE `members` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text,`created_at` datetime);
diff --git a/internal/sheets/store/handle_grid.go b/internal/sheets/store/handle_grid.go
index 2a8b6b6..1b90513 100644
--- a/internal/sheets/store/handle_grid.go
+++ b/internal/sheets/store/handle_grid.go
@@ -225,6 +225,12 @@ func formatDigest(f Format) string {
sb.WriteByte('|')
sb.WriteString(f.FillHex)
sb.WriteByte('|')
+ // InkHex participates for the same reason every other field does:
+ // formats are interned by this digest, so a field left out here
+ // makes two genuinely different formats collide onto one row, and
+ // the second cell silently adopts the first's colour.
+ sb.WriteString(f.InkHex)
+ sb.WriteByte('|')
sb.WriteString(f.Align)
sum := sha256.Sum256([]byte(sb.String()))
return hex.EncodeToString(sum[:])
diff --git a/internal/sheets/store/models.go b/internal/sheets/store/models.go
index fd31481..a1383bc 100644
--- a/internal/sheets/store/models.go
+++ b/internal/sheets/store/models.go
@@ -93,7 +93,18 @@ type Format struct {
FillIntentH *float64 // OKLCH hue; nil means no fill
FillIntentC *float64 // OKLCH chroma
FillHex string // retained from XLSX import for lossless re-export
- Align string
+ // InkHex is the AUTHOR'S font colour, retained from XLSX import the
+ // same way FillHex is, and for the same reason: re-exporting a file
+ // with a colour we invented would lie about it.
+ //
+ // It exists because spec §11.1's obligation needs a pair. A fill
+ // alone cannot fail a contrast check — a fill plus the ink somebody
+ // chose to put on it can, and Excel ships such a pair as a preset
+ // (#9C6500 on #FFEB9C, 4.12:1). Empty means the file said nothing,
+ // and the theme's own ink is used, which is a pairing this app
+ // controls and cannot get wrong.
+ InkHex string
+ Align string
}
// Event is the append-only log: live-sync fan-out, version history, undo
diff --git a/internal/sheets/templates/layout.html b/internal/sheets/templates/layout.html
index e3e84e7..5137f7b 100644
--- a/internal/sheets/templates/layout.html
+++ b/internal/sheets/templates/layout.html
@@ -59,3 +59,23 @@
</body>
</html>
{{end}}
+
+{{define "contrast-notice"}}
+{{if .}}
+{{/* Spec §11.1: a retained fill can be unreadable, and that is
+ correct. We render an imported file as authored — silently
+ correcting it would lie about the document — so the obligation
+ that follows is to say so and leave the decision to the person.
+ This is that, and it is deliberately a notice rather than a
+ change to any cell. */}}
+<div rst-callout="warn">
+<div rst-callout-body>
+<p><strong>Imported colours below the contrast floor.</strong>
+These cells came from a file pairing a fill and a text colour under
+WCAG AA's 4.5:1: {{range $i, $ref := .}}{{if $i}}, {{end}}<code>{{$ref}}</code>{{end}}</p>
+<p>They are shown exactly as the file wrote them. Changing a colour is
+yours to decide, not ours to do quietly.</p>
+</div>
+</div>
+{{end}}
+{{end}}
diff --git a/internal/sheets/templates/sheet.html b/internal/sheets/templates/sheet.html
index 0982227..668dd36 100644
--- a/internal/sheets/templates/sheet.html
+++ b/internal/sheets/templates/sheet.html
@@ -57,5 +57,6 @@ read every cell, edit any of them, one page at a time.</p>
</div>
</noscript>
+{{template "contrast-notice" .Data.Warned}}
<div data-sheet-grid></div>
{{end}}
diff --git a/internal/sheets/templates/table.html b/internal/sheets/templates/table.html
index 0bade45..466f87e 100644
--- a/internal/sheets/templates/table.html
+++ b/internal/sheets/templates/table.html
@@ -8,6 +8,7 @@ Every cell is editable here, with no JavaScript at all.</p>
<a rst-btn="primary" href="/sheets/{{.Data.Sheet.ID}}">Open the grid</a>
</header>
+{{template "contrast-notice" .Data.Warned}}
<div style="overflow-x:auto">
<table data-cell-table>
<caption class="rst-sr-only">{{.Data.Sheet.Title}}, page {{.Data.Page}} of {{.Data.Pages}}</caption>
diff --git a/internal/sheets/xlsx/format.go b/internal/sheets/xlsx/format.go
index 1a2be31..65fc341 100644
--- a/internal/sheets/xlsx/format.go
+++ b/internal/sheets/xlsx/format.go
@@ -31,8 +31,17 @@ func styleFromFormat(f *store.Format) (excelize.Style, bool) {
st.CustomNumFmt = &nf
used = true
}
- if f.Bold || f.Italic {
+ // The condition includes InkHex, not just bold and italic: a cell
+ // whose only font property is a colour would otherwise get no Font
+ // element at all, and the retained ink would be dropped on export —
+ // silently, and only for the cells §11.1 is about.
+ if f.Bold || f.Italic || f.InkHex != "" {
st.Font = &excelize.Font{Bold: f.Bold, Italic: f.Italic}
+ if len(f.InkHex) == 8 {
+ // Same 6-char RGB convention as Fill.Color below: excelize
+ // prepends its own alpha.
+ st.Font.Color = f.InkHex[2:]
+ }
used = true
}
if f.Align != "" {
@@ -82,6 +91,15 @@ func formatFromStyle(st *excelize.Style) (store.Format, bool) {
f.Italic = st.Font.Italic
used = true
}
+ if st.Font != nil && st.Font.Color != "" {
+ // Retained, not resolved. Spec §11.1: we render an imported
+ // file as authored, and surface a pairing that fails AA rather
+ // than silently correcting it — correcting would lie about the
+ // document, which is the same class of wrong as inventing a
+ // font colour on export.
+ f.InkHex = argbFromStyleColor(st.Font.Color)
+ used = true
+ }
if st.Alignment != nil && st.Alignment.Horizontal != "" {
f.Align = st.Alignment.Horizontal
used = true