From 37a3651563007f4c96d72bba071568bb7ec3d731 Mon Sep 17 00:00:00 2001 From: Hugo Wetterberg Date: Tue, 17 Sep 2024 09:51:50 +0200 Subject: [PATCH] add separate colour format --- README.md | 28 ++--- colour.go | 183 ++++++++++++++++++++++-------- string_constraint.go | 59 +++++----- testdata/colour.json | 22 +++- testdata/constraints/colour.json | 12 +- testdata/results/test-colour.json | 38 ++++++- 6 files changed, 236 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index ab670ba..18270ed 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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). diff --git a/colour.go b/colour.go index f817ec4..faed050 100644 --- a/colour.go +++ b/colour.go @@ -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, ", ") +} diff --git a/string_constraint.go b/string_constraint.go index 933ea29..c94ca05 100644 --- a/string_constraint.go +++ b/string_constraint.go @@ -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 { @@ -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 "" } @@ -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 @@ -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) } diff --git a/testdata/colour.json b/testdata/colour.json index 373053c..2d664c2 100644 --- a/testdata/colour.json +++ b/testdata/colour.json @@ -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" } } ] diff --git a/testdata/constraints/colour.json b/testdata/constraints/colour.json index ffb5244..dc5a5a7 100644 --- a/testdata/constraints/colour.json +++ b/testdata/constraints/colour.json @@ -10,14 +10,22 @@ "data": { "hexy": { "format": "colour", + "colourFormats": ["hex"], "optional": true }, "solid": { - "format": "colour_rgb", + "format": "colour", + "colourFormats": ["rgb"], "optional": true }, "transparent": { - "format": "colour_rgba", + "format": "colour", + "colourFormats": ["rgba"], + "optional": true + }, + "anycol": { + "format": "colour", + "colourFormats": ["rgba", "rgb", "hex"], "optional": true } } diff --git a/testdata/results/test-colour.json b/testdata/results/test-colour.json index 5621497..14e7e88 100644 --- a/testdata/results/test-colour.json +++ b/testdata/results/test-colour.json @@ -12,7 +12,7 @@ "type": "test/colour" } ], - "error": "invalid colour value \"feqefe\": invalid hex code: encoding/hex: invalid byte: U+0071 'q'" + "error": "invalid colour value \"#feqefe\": invalid hex code: encoding/hex: invalid byte: U+0071 'q'" }, { "entity": [ @@ -57,7 +57,7 @@ "type": "test/colour" } ], - "error": "invalid colour value \"fefefefe\": code length: expected 6 characters, got 8" + "error": "invalid colour value \"#fefefefe\": code length: expected 6 characters, got 8" }, { "entity": [ @@ -72,7 +72,7 @@ "type": "test/colour" } ], - "error": "invalid colour value \"rgba(10,10,10,0.3)\": expected \"rgb\" colour" + "error": "invalid colour value \"rgba(10,10,10,0.3)\": expected a colour in the format \"rgb\"" }, { "entity": [ @@ -87,6 +87,36 @@ "type": "test/colour" } ], - "error": "invalid colour value \"rgb(10, 10, 10)\": expected \"rgba\" colour" + "error": "invalid colour value \"rgb(10, 10, 10)\": expected a colour in the format \"rgba\"" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "anycol" + }, + { + "refType": "block", + "kind": "content", + "index": 3, + "type": "test/colour" + } + ], + "error": "invalid colour value \"nope\": expected a colour in one of the formats \"rgba\", \"rgb\", \"hex\"" + }, + { + "entity": [ + { + "refType": "data attribute", + "name": "hexy" + }, + { + "refType": "block", + "kind": "content", + "index": 3, + "type": "test/colour" + } + ], + "error": "invalid colour value \"fefefe\": expected a colour in the format \"hex\"" } ]