Skip to content

Commit

Permalink
add support for hex/rgb/rgba colour formats (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
hugowetterberg authored Sep 17, 2024
1 parent b5f799c commit c0474c7
Show file tree
Hide file tree
Showing 7 changed files with 400 additions and 27 deletions.
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,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 @@ -141,6 +142,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
163 changes: 163 additions & 0 deletions colour.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package revisor

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

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 (
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, 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 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)
}

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])
}
default:
return fmt.Errorf(
"configuration error: cannot parse %q with parseRGBA()",
spec.Format,
)
}

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])
}
}

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, ", ")
}
37 changes: 23 additions & 14 deletions string_constraint.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const (
StringFormatHTML StringFormat = "html"
StringFormatUUID StringFormat = "uuid"
StringFormatWKT StringFormat = "wkt"
StringFormatColour StringFormat = "colour"
)

func (f StringFormat) Describe() string {
Expand All @@ -41,6 +42,8 @@ func (f StringFormat) Describe() string {
return "a uuid"
case StringFormatWKT:
return "a WKT geometry"
case StringFormatColour:
return "a colour code"
case StringFormatNone:
return ""
}
Expand Down Expand Up @@ -117,20 +120,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 @@ -281,6 +285,11 @@ func (sc *StringConstraint) Validate(
if err != nil {
return nil, fmt.Errorf("WKT validation: %w", err)
}
case StringFormatColour:
err := validateColour(value, sc.ColourFormats)
if err != nil {
return nil, fmt.Errorf("invalid colour value %q: %w", value, err)
}
default:
return nil, fmt.Errorf("unknown string format %q", sc.Format)
}
Expand Down
40 changes: 40 additions & 0 deletions testdata/colour.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"uuid": "67912d12-bea0-4ec3-a8d4-16d669bd35bc",
"type": "test/colour-doc",
"content": [
{
"type": "test/colour",
"data": {
"hexy": "#fefefe",
"solid": "rgb(10,10,10)",
"transparent": "rgba(10, 10, 10, 0.5)",
"anycol": "rgba(10, 10, 10, 0.5)"
}
},
{
"type": "test/colour",
"data": {
"hexy": "#feqefe",
"solid": "rgb(-1,10,10)",
"transparent": "rgba(10, 10, 10, 2)",
"anycol": "rgb(10, 10, 10)"
}
},
{
"type": "test/colour",
"data": {
"hexy": "#fefefefe",
"solid": "rgba(10,10,10,0.3)",
"transparent": "rgb(10, 10, 10)",
"anycol": "#ab332a"
}
},
{
"type": "test/colour",
"data": {
"hexy": "fefefe",
"anycol": "nope"
}
}
]
}
36 changes: 36 additions & 0 deletions testdata/constraints/colour.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"version": 1,
"name": "colour",
"documents": [
{
"declares": "test/colour-doc",
"content": [
{
"declares": {"type": "test/colour"},
"data": {
"hexy": {
"format": "colour",
"colourFormats": ["hex"],
"optional": true
},
"solid": {
"format": "colour",
"colourFormats": ["rgb"],
"optional": true
},
"transparent": {
"format": "colour",
"colourFormats": ["rgba"],
"optional": true
},
"anycol": {
"format": "colour",
"colourFormats": ["rgba", "rgb", "hex"],
"optional": true
}
}
}
]
}
]
}
Loading

0 comments on commit c0474c7

Please sign in to comment.