Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for RGB hex colours #3

Merged
merged 5 commits into from
Sep 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading