| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "html/template" |
| 5 | "regexp" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // The thumbnail's geometry. |
| 11 | // |
| 12 | // 26 by 34, matching the Docs app's slot exactly and by agreement. A |
| 13 | // spreadsheet is a landscape thing and this box is not, which is a |
| 14 | // deliberate trade: the two apps' file lists have to have the same row |
| 15 | // height or they stop reading as one product, and that matters more |
| 16 | // than the aspect of a picture two centimetres tall. |
| 17 | const ( |
| 18 | previewW = 26.0 |
| 19 | previewH = 34.0 |
| 20 | // The bar inside a cell, as a fraction of the cell. A mark, not a |
| 21 | // fill: the cell is where the data is, the bar is that there is |
| 22 | // some. |
| 23 | previewInsetX = 0.14 |
| 24 | previewBarTop = 0.28 |
| 25 | previewBarH = 0.44 |
| 26 | ) |
| 27 | |
| 28 | // safeHex accepts a fill hex from a document and returns a CSS colour, |
| 29 | // or "". |
| 30 | // |
| 31 | // Six or eight hex digits and nothing else. The value came out of a |
| 32 | // spreadsheet somebody uploaded and is about to be interpolated into an |
| 33 | // SVG attribute, so this is the check that keeps a crafted "colour" |
| 34 | // from being anything but a colour. html/template would escape the |
| 35 | // attribute anyway; this makes the value correct rather than merely |
| 36 | // harmless. |
| 37 | var hexOnly = regexp.MustCompile(`^[0-9A-Fa-f]{6}$|^[0-9A-Fa-f]{8}$`) |
| 38 | |
| 39 | func safeHex(v string) string { |
| 40 | if !hexOnly.MatchString(v) { |
| 41 | return "" |
| 42 | } |
| 43 | if len(v) == 8 { |
| 44 | v = v[2:] // XLSX writes ARGB; the alpha is always FF here |
| 45 | } |
| 46 | return "#" + v |
| 47 | } |
| 48 | |
| 49 | // trimFloat renders a number for a CSS function without an exponent or |
| 50 | // a trailing run of zeroes, so the markup is stable and short. |
| 51 | func trimFloat(f float64) string { |
| 52 | s := strconv.FormatFloat(f, 'f', 4, 64) |
| 53 | s = strings.TrimRight(s, "0") |
| 54 | return strings.TrimSuffix(s, ".") |
| 55 | } |
| 56 | |
| 57 | // previewSVG renders a thumbnail. |
| 58 | // |
| 59 | // Inline SVG, built server-side, on read. Not a raster and not a file: |
| 60 | // a rendered preview in the bucket would be an object derived from a |
| 61 | // sealed sheet, and the argument about whether it counts as content is |
| 62 | // one this app should never have to have. It also lets the SVG use the |
| 63 | // page's own custom properties — --sheet-fill-l resolves per theme, so |
| 64 | // the thumbnail follows light and dark the way the grid does. |
| 65 | // |
| 66 | // The returned value is template.HTML because it IS markup. Every |
| 67 | // value interpolated into it is a number this file formatted or a |
| 68 | // colour safeHex validated; nothing from a document reaches it |
| 69 | // unchecked. |
| 70 | func previewSVG(p preview) template.HTML { |
| 71 | var b strings.Builder |
| 72 | b.WriteString(`<svg viewBox="0 0 26 34" width="26" height="34" `) |
| 73 | b.WriteString(`role="img" aria-hidden="true" focusable="false" data-preview>`) |
| 74 | b.WriteString(`<rect x="0.5" y="0.5" width="25" height="33" rx="1.5" `) |
| 75 | b.WriteString(`fill="var(--rst-surface)" stroke="var(--rst-line)"/>`) |
| 76 | |
| 77 | if !p.Empty && p.Rows > 0 && p.Cols > 0 { |
| 78 | cw := previewW / float64(p.Cols) |
| 79 | ch := previewH / float64(p.Rows) |
| 80 | for _, c := range p.Cells { |
| 81 | x := float64(c.C) * cw |
| 82 | y := float64(c.R) * ch |
| 83 | |
| 84 | // The fill covers the whole cell, because that is what a |
| 85 | // fill does. It is drawn first so the bar sits on it. |
| 86 | if c.Fill != "" { |
| 87 | rect(&b, x, y, cw, ch, c.Fill, 0) |
| 88 | } |
| 89 | // The bar stands in for content. Bold cells are darker, |
| 90 | // which is what makes a header row read as a header row at |
| 91 | // this size — the one piece of formatting that survives |
| 92 | // being two millimetres tall. |
| 93 | ink := "var(--rst-text-faint)" |
| 94 | if c.Bold { |
| 95 | ink = "var(--rst-text-muted)" |
| 96 | } |
| 97 | rect(&b, |
| 98 | x+cw*previewInsetX, y+ch*previewBarTop, |
| 99 | cw*(1-2*previewInsetX), ch*previewBarH, |
| 100 | ink, 0.4) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | b.WriteString(`</svg>`) |
| 105 | return template.HTML(b.String()) |
| 106 | } |
| 107 | |
| 108 | func rect(b *strings.Builder, x, y, w, h float64, fill string, rx float64) { |
| 109 | b.WriteString(`<rect x="` + trimFloat(x) + `" y="` + trimFloat(y) + |
| 110 | `" width="` + trimFloat(w) + `" height="` + trimFloat(h) + `"`) |
| 111 | if rx > 0 { |
| 112 | b.WriteString(` rx="` + trimFloat(rx) + `"`) |
| 113 | } |
| 114 | b.WriteString(` fill="` + fill + `"/>`) |
| 115 | } |
| 116 | |