| 1 | package layout |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | |
| 9 | "amadan.net/oficina/slides/internal/slides/deck" |
| 10 | ) |
| 11 | |
| 12 | // props.go reads a block's free-form props, and turns the colours a |
| 13 | // deck may name into the `#rrggbb` a paint list carries. |
| 14 | // |
| 15 | // Every read takes a default and every enumerated value is checked |
| 16 | // against its vocabulary, for the reason render's own props section |
| 17 | // gives: props arrive from a peer mid-edit and can hold anything at |
| 18 | // all. A prop of the wrong type takes its default rather than |
| 19 | // travelling any further. |
| 20 | |
| 21 | func stringProp(props map[string]any, key string) string { |
| 22 | s, _ := props[key].(string) |
| 23 | return s |
| 24 | } |
| 25 | |
| 26 | func numProp(props map[string]any, key string, def float64) float64 { |
| 27 | switch v := props[key].(type) { |
| 28 | case float64: |
| 29 | return v |
| 30 | case int: |
| 31 | return float64(v) |
| 32 | case json.Number: |
| 33 | if f, err := v.Float64(); err == nil { |
| 34 | return f |
| 35 | } |
| 36 | } |
| 37 | return def |
| 38 | } |
| 39 | |
| 40 | func boolProp(props map[string]any, key string) bool { |
| 41 | b, _ := props[key].(bool) |
| 42 | return b |
| 43 | } |
| 44 | |
| 45 | // sizeProp is §2.4's s, m, l — the step, not the size. |
| 46 | func sizeProp(props map[string]any, def string) string { |
| 47 | switch s, _ := props["size"].(string); s { |
| 48 | case "s", "m", "l": |
| 49 | return s |
| 50 | } |
| 51 | return def |
| 52 | } |
| 53 | |
| 54 | // alignProp is a text block's own alignment, or the region's when it |
| 55 | // has none. `justify` lays out as `start` here: justification changes |
| 56 | // where the words sit on a line and not where the line breaks, so it |
| 57 | // is a serialiser's job (SVG has no justified text at all) and this |
| 58 | // package refusing to guess is better than it guessing differently |
| 59 | // from the browser. |
| 60 | func alignProp(props map[string]any, def Align) Align { |
| 61 | switch a, _ := props["align"].(string); a { |
| 62 | case "center": |
| 63 | return Center |
| 64 | case "end": |
| 65 | return End |
| 66 | case "start", "justify": |
| 67 | return Start |
| 68 | } |
| 69 | return def |
| 70 | } |
| 71 | |
| 72 | // childIDs is a columns block's props.children (§2.4). |
| 73 | func childIDs(b deck.Block) []string { |
| 74 | switch v := b.Props["children"].(type) { |
| 75 | case []string: |
| 76 | return v |
| 77 | case []any: |
| 78 | out := make([]string, 0, len(v)) |
| 79 | for _, c := range v { |
| 80 | if s, ok := c.(string); ok { |
| 81 | out = append(out, s) |
| 82 | } |
| 83 | } |
| 84 | return out |
| 85 | } |
| 86 | return nil |
| 87 | } |
| 88 | |
| 89 | // cellsProp reads a table's props.cells: a row-major array of texts |
| 90 | // (core/src/view.rs). Back through JSON rather than walked by hand, |
| 91 | // for render's reason — the view built it with encoding/json's shapes, |
| 92 | // and a round trip is shorter and harder to get wrong than five type |
| 93 | // assertions deep. |
| 94 | func cellsProp(props map[string]any) []deck.Text { |
| 95 | raw, ok := props["cells"] |
| 96 | if !ok { |
| 97 | return nil |
| 98 | } |
| 99 | b, err := json.Marshal(raw) |
| 100 | if err != nil { |
| 101 | return nil |
| 102 | } |
| 103 | var out []deck.Text |
| 104 | if err := json.Unmarshal(b, &out); err != nil { |
| 105 | return nil |
| 106 | } |
| 107 | return out |
| 108 | } |
| 109 | |
| 110 | // ── Colours ────────────────────────────────────────────────────────── |
| 111 | |
| 112 | // basicColors is CSS's sixteen original keywords, which is what a |
| 113 | // person typing a colour by hand into a shape actually types. A |
| 114 | // keyword outside it resolves to the fallback the caller names rather |
| 115 | // than travelling into a PDF, where "rebeccapurple" is not a colour |
| 116 | // anything can draw. |
| 117 | var basicColors = map[string]Fill{ |
| 118 | "black": "#000000", "silver": "#c0c0c0", "gray": "#808080", "grey": "#808080", |
| 119 | "white": "#ffffff", "maroon": "#800000", "red": "#ff0000", "purple": "#800080", |
| 120 | "fuchsia": "#ff00ff", "green": "#008000", "lime": "#00ff00", "olive": "#808000", |
| 121 | "yellow": "#ffff00", "navy": "#000080", "blue": "#0000ff", "teal": "#008080", |
| 122 | "aqua": "#00ffff", |
| 123 | } |
| 124 | |
| 125 | // color resolves a colour a deck named into one this package can |
| 126 | // paint: `#rgb` and `#rrggbb` verbatim, `currentColor` as the slide's |
| 127 | // foreground, `none` and `transparent` as no colour at all, one of the |
| 128 | // basic keywords as its hex, and anything else as def. |
| 129 | // |
| 130 | // An alpha channel is dropped, with the colour kept: a paint list has |
| 131 | // no opacity (the SVG and the PDF would disagree about compositing it) |
| 132 | // and dropping the channel loses less than dropping the colour. |
| 133 | func (e *engine) color(v string, def Fill) Fill { |
| 134 | v = strings.TrimSpace(v) |
| 135 | switch strings.ToLower(v) { |
| 136 | case "": |
| 137 | return def |
| 138 | case "currentcolor": |
| 139 | return e.fg |
| 140 | case "none", "transparent": |
| 141 | return "" |
| 142 | } |
| 143 | if strings.HasPrefix(v, "#") { |
| 144 | if hex, ok := normalizeHex(v); ok { |
| 145 | return hex |
| 146 | } |
| 147 | return def |
| 148 | } |
| 149 | if c, ok := basicColors[strings.ToLower(v)]; ok { |
| 150 | return c |
| 151 | } |
| 152 | return def |
| 153 | } |
| 154 | |
| 155 | // normalizeHex widens #rgb to #rrggbb, lowercases, and drops an alpha |
| 156 | // channel. |
| 157 | func normalizeHex(v string) (Fill, bool) { |
| 158 | h := strings.ToLower(v[1:]) |
| 159 | switch len(h) { |
| 160 | case 4: // #rgba |
| 161 | h = h[:3] |
| 162 | case 8: // #rrggbbaa |
| 163 | h = h[:6] |
| 164 | } |
| 165 | if len(h) == 3 { |
| 166 | h = string([]byte{h[0], h[0], h[1], h[1], h[2], h[2]}) |
| 167 | } |
| 168 | if len(h) != 6 { |
| 169 | return "", false |
| 170 | } |
| 171 | for _, c := range h { |
| 172 | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { |
| 173 | return "", false |
| 174 | } |
| 175 | } |
| 176 | return Fill("#" + h), true |
| 177 | } |
| 178 | |
| 179 | // mix is the stylesheet's color-mix(in oklab, A p%, B), done in plain |
| 180 | // sRGB. |
| 181 | // |
| 182 | // It is an approximation and the only one in this package that is not |
| 183 | // arithmetic: oklab mixing is perceptual and this is not, so a panel |
| 184 | // mixed here is a shade or two off the browser's. It is used for the |
| 185 | // four greys the stylesheet builds that way — a code panel, a table |
| 186 | // rule, a divider, a highlight — where being a shade off is invisible |
| 187 | // and being absent is not. Getting it exact means an oklab conversion |
| 188 | // nothing else here needs. |
| 189 | func mix(a, b Fill, frac float64) Fill { |
| 190 | ca, ok1 := parseHex(a) |
| 191 | cb, ok2 := parseHex(b) |
| 192 | if !ok1 || !ok2 { |
| 193 | return b |
| 194 | } |
| 195 | var out [3]int |
| 196 | for i := range out { |
| 197 | v := float64(ca[i])*frac + float64(cb[i])*(1-frac) |
| 198 | out[i] = int(v + 0.5) |
| 199 | } |
| 200 | return Fill(fmt.Sprintf("#%02x%02x%02x", out[0], out[1], out[2])) |
| 201 | } |
| 202 | |
| 203 | func parseHex(f Fill) ([3]int, bool) { |
| 204 | s := string(f) |
| 205 | if len(s) != 7 || s[0] != '#' { |
| 206 | return [3]int{}, false |
| 207 | } |
| 208 | var out [3]int |
| 209 | for i := range out { |
| 210 | v, err := strconv.ParseUint(s[1+i*2:3+i*2], 16, 8) |
| 211 | if err != nil { |
| 212 | return [3]int{}, false |
| 213 | } |
| 214 | out[i] = int(v) |
| 215 | } |
| 216 | return out, true |
| 217 | } |
| 218 | |