Skip to content

Commit

Permalink
add separate colour format
Browse files Browse the repository at this point in the history
  • Loading branch information
hugowetterberg committed Sep 17, 2024
1 parent 0a703ff commit 37a3651
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 106 deletions.
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,20 @@ Here we declare that links with `rel` "broader" are valid for all blocks that ma

### String constraints

| Name | Use |
|:-----------|:---------------------------------------------------------------------------|
| optional | Set to `true` if the value doesn't have to be present |
| allowEmpty | Set to `true` if an empty value is ok. |
| const | A specific `"value"` that must match |
| enum | A list `["of", "values"]` where one must match |
| pattern | A regular expression that the value must match |
| glob | A list of glob patterns `["http://**", "https://**"]` where one must match |
| format | A named format that the value must follow |
| time | A time format specification |
| geometry | The geometry and coordinate type that must be used for WKT strings. |
| labels | Labels used to describe the value |
| hints | Key value pairs used to describe the value |
| Name | Use |
|:--------------|:-----------------------------------------------------------------------------------------------------------|
| optional | Set to `true` if the value doesn't have to be present |
| allowEmpty | Set to `true` if an empty value is ok. |
| const | A specific `"value"` that must match |
| enum | A list `["of", "values"]` where one must match |
| pattern | A regular expression that the value must match |
| glob | A list of glob patterns `["http://**", "https://**"]` where one must match |
| format | A named format that the value must follow |
| time | A time format specification |
| colourFormats | Controls the "colour" format. Any combination of "hex", "rgb", and "rgba". Defaults to `["rgb", "rgba"]`. |
| geometry | The geometry and coordinate type that must be used for WKT strings. |
| labels | Labels used to describe the value |
| hints | Key value pairs used to describe the value |

The distinction between optional and allowEmpty is only relevant for data attributes. The document and block attributes defined in the NewsDoc schema always exist, so `optional` and `allowEmpty` will be treated as equivalent.

Expand All @@ -115,6 +116,7 @@ The following formats are available:
* `html`: validate the contents as HTML
* `uuid`: validate the string as a UUID
* `wkt`: validate the string as a [WKT geometry](#wkt-geometry).
* `colour`: a colour in one of the formats specified in `colourFormats`.

When using the format "html" it's also possible to use `htmlPolicy` to use a specific HTML policy. See the section on [HTML policies](#markdown-header-html-policies).

Expand Down
183 changes: 134 additions & 49 deletions colour.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,77 +2,162 @@ package revisor

import (
"encoding/hex"
"errors"
"fmt"
"slices"
"strconv"
"strings"
)

const colourLength = 6
type ColourFormat string

const (
ColourUnknown ColourFormat = ""
ColourHex ColourFormat = "hex"
ColourRGB ColourFormat = "rgb"
ColourRGBA ColourFormat = "rgba"
)

type cFormatSpec struct {
Format ColourFormat
Prefix string
Validate func(spec cFormatSpec, code string) error
}

var (
colourComponents = []string{"r", "g", "b", "alpha"}
colourKeyword = map[StringFormat]string{
StringFormatColourRGB: "rgb",
StringFormatColourRGBA: "rgba",
defaultColourFormats = []ColourFormat{ColourRGB, ColourRGBA}
colourComponents = []string{"r", "g", "b", "alpha"}
colourFormats = []cFormatSpec{
{
Format: ColourHex,
Prefix: "#",
Validate: parseHex,
},
{
Format: ColourRGBA,
Prefix: "rgba",
Validate: parseRGBA,
},
{
Format: ColourRGB,
Prefix: "rgb",
Validate: parseRGBA,
},
}
)

func validateColour(value string, format StringFormat) error {
func validateColour(value string, formats []ColourFormat) error {
var (
spec cFormatSpec
code string
)

for _, s := range colourFormats {
after, ok := strings.CutPrefix(value, s.Prefix)
if !ok {
continue
}

spec = s
code = after

break
}

if len(formats) == 0 {
formats = defaultColourFormats
}

if spec.Format == ColourUnknown || !slices.Contains(formats, spec.Format) {
if len(formats) == 1 {
return fmt.Errorf("expected a colour in the format %q",
formats[0])
}

return fmt.Errorf("expected a colour in one of the formats %s",
quotedSlice(formats))
}

return spec.Validate(spec, code)
}

const hexColourLength = 6

func parseHex(_ cFormatSpec, code string) error {
if len(code) != hexColourLength {
return fmt.Errorf("code length: expected %d characters, got %d",
hexColourLength, len(code))
}

_, err := hex.DecodeString(code)
if err != nil {
return fmt.Errorf("invalid hex code: %w", err)
}

return nil
}

func parseRGBA(spec cFormatSpec, code string) error {
rest, ok := strings.CutPrefix(code, "(")
if !ok {
return errors.New("missing starting '('")
}

rest, ok = strings.CutSuffix(rest, ")")
if !ok {
return errors.New("missing closing ')'")
}

numberStrings := strings.Split(rest, ",")
components := len(numberStrings)

//nolint: exhaustive
switch format {
case StringFormatColour:
if len(value) != colourLength {
return fmt.Errorf("code length: expected %d characters, got %d",
colourLength, len(value))
switch spec.Format {
case ColourRGB:
if components != 3 {
return fmt.Errorf("expected three components in a rgb() value, got %d", components)
}
case ColourRGBA:
if components != 4 {
return fmt.Errorf("expected four components in a rgba() value, got %d", components)
}

_, err := hex.DecodeString(value)
n, err := strconv.ParseFloat(strings.TrimSpace(numberStrings[3]), 64)
if err != nil {
return fmt.Errorf("invalid hex code: %w", err)
return fmt.Errorf("invalid alpha value: %w", err)
}
case StringFormatColourRGB, StringFormatColourRGBA:
keyword := colourKeyword[format]

f, rest, ok := strings.Cut(value, "(")
if !ok || f != keyword {
return fmt.Errorf("expected %q colour", keyword)
if n < 0 || n > 1 {
return fmt.Errorf("%q out of range", colourComponents[3])
}
default:
return fmt.Errorf(
"configuration error: cannot parse %q with parseRGBA()",
spec.Format,
)
}

numberStrings := strings.Split(strings.Trim(rest, " )"), ",")
components := len(numberStrings)

switch f {
case "rgb":
if components != 3 {
return fmt.Errorf("expected three components in a rgb() value, got %d", components)
}
case "rgba":
if components != 4 {
return fmt.Errorf("expected four components in a rgba() value, got %d", components)
}

n, err := strconv.ParseFloat(strings.TrimSpace(numberStrings[3]), 64)
if err != nil {
return fmt.Errorf("invalid alpha value: %w", err)
}

if n < 0 || n > 1 {
return fmt.Errorf("%q out of range", colourComponents[3])
}
for i, ns := range numberStrings[:3] {
n, err := strconv.Atoi(strings.TrimSpace(ns))
if err != nil {
return fmt.Errorf("invalid %q value: %w",
colourComponents[i], err)
}

for i, ns := range numberStrings[:3] {
n, err := strconv.Atoi(strings.TrimSpace(ns))
if err != nil {
return fmt.Errorf("invalid %q value: %w",
colourComponents[i], err)
}

if n < 0 || n > 255 {
return fmt.Errorf("%q out of range", colourComponents[i])
}
if n < 0 || n > 255 {
return fmt.Errorf("%q out of range", colourComponents[i])
}
}

return nil
}

func quotedSlice[T any](s []T) string {
ss := make([]string, len(s))

for i, v := range s {
ss[i] = strconv.Quote(fmt.Sprintf("%v", v))
}

return strings.Join(ss, ", ")
}
59 changes: 27 additions & 32 deletions string_constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,15 @@ import (
type StringFormat string

const (
StringFormatNone StringFormat = ""
StringFormatRFC3339 StringFormat = "RFC3339"
StringFormatInt StringFormat = "int"
StringFormatFloat StringFormat = "float"
StringFormatBoolean StringFormat = "bool"
StringFormatHTML StringFormat = "html"
StringFormatUUID StringFormat = "uuid"
StringFormatWKT StringFormat = "wkt"
StringFormatColour StringFormat = "colour"
StringFormatColourRGB StringFormat = "colour_rgb"
StringFormatColourRGBA StringFormat = "colour_rgba"
StringFormatNone StringFormat = ""
StringFormatRFC3339 StringFormat = "RFC3339"
StringFormatInt StringFormat = "int"
StringFormatFloat StringFormat = "float"
StringFormatBoolean StringFormat = "bool"
StringFormatHTML StringFormat = "html"
StringFormatUUID StringFormat = "uuid"
StringFormatWKT StringFormat = "wkt"
StringFormatColour StringFormat = "colour"
)

func (f StringFormat) Describe() string {
Expand All @@ -45,11 +43,7 @@ func (f StringFormat) Describe() string {
case StringFormatWKT:
return "a WKT geometry"
case StringFormatColour:
return "a hex RGB colour code"
case StringFormatColourRGB:
return "a rgb() colour code"
case StringFormatColourRGBA:
return "a rgba() colour code"
return "a colour code"
case StringFormatNone:
return ""
}
Expand Down Expand Up @@ -107,20 +101,21 @@ func (cm ConstraintMap) MarshalJSON() ([]byte, error) {
}

type StringConstraint struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Optional bool `json:"optional,omitempty"`
AllowEmpty bool `json:"allowEmpty,omitempty"`
Const *string `json:"const,omitempty"`
Enum []string `json:"enum,omitempty"`
EnumRef string `json:"enumReference,omitempty"`
Pattern *Regexp `json:"pattern,omitempty"`
Glob GlobList `json:"glob,omitempty"`
Format StringFormat `json:"format,omitempty"`
Time string `json:"time,omitempty"`
Geometry string `json:"geometry,omitempty"`
HTMLPolicy string `json:"htmlPolicy,omitempty"`
Deprecated *Deprecation `json:"deprecated,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Optional bool `json:"optional,omitempty"`
AllowEmpty bool `json:"allowEmpty,omitempty"`
Const *string `json:"const,omitempty"`
Enum []string `json:"enum,omitempty"`
EnumRef string `json:"enumReference,omitempty"`
Pattern *Regexp `json:"pattern,omitempty"`
Glob GlobList `json:"glob,omitempty"`
Format StringFormat `json:"format,omitempty"`
Time string `json:"time,omitempty"`
Geometry string `json:"geometry,omitempty"`
ColourFormats []ColourFormat `json:"colourFormats,omitempty"`
HTMLPolicy string `json:"htmlPolicy,omitempty"`
Deprecated *Deprecation `json:"deprecated,omitempty"`

// Labels (and hints) are not constraints per se, but should be seen as
// labels on the value that can be used by systems that process data
Expand Down Expand Up @@ -271,8 +266,8 @@ func (sc *StringConstraint) Validate(
if err != nil {
return nil, fmt.Errorf("WKT validation: %w", err)
}
case StringFormatColour, StringFormatColourRGB, StringFormatColourRGBA:
err := validateColour(value, sc.Format)
case StringFormatColour:
err := validateColour(value, sc.ColourFormats)
if err != nil {
return nil, fmt.Errorf("invalid colour value %q: %w", value, err)
}
Expand Down
22 changes: 16 additions & 6 deletions testdata/colour.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,35 @@
{
"type": "test/colour",
"data": {
"hexy": "fefefe",
"hexy": "#fefefe",
"solid": "rgb(10,10,10)",
"transparent": "rgba(10, 10, 10, 0.5)"
"transparent": "rgba(10, 10, 10, 0.5)",
"anycol": "rgba(10, 10, 10, 0.5)"
}
},
{
"type": "test/colour",
"data": {
"hexy": "feqefe",
"hexy": "#feqefe",
"solid": "rgb(-1,10,10)",
"transparent": "rgba(10, 10, 10, 2)"
"transparent": "rgba(10, 10, 10, 2)",
"anycol": "rgb(10, 10, 10)"
}
},
{
"type": "test/colour",
"data": {
"hexy": "fefefefe",
"hexy": "#fefefefe",
"solid": "rgba(10,10,10,0.3)",
"transparent": "rgb(10, 10, 10)"
"transparent": "rgb(10, 10, 10)",
"anycol": "#ab332a"
}
},
{
"type": "test/colour",
"data": {
"hexy": "fefefe",
"anycol": "nope"
}
}
]
Expand Down
Loading

0 comments on commit 37a3651

Please sign in to comment.