Skip to content

Commit

Permalink
Relax color parser based on the current and upcoming CSS specs
Browse files Browse the repository at this point in the history
This adds supports for:
- fractional RGB values, e.g. "rgb(255.0, 0.0, 0.0)"
- fractional percentages, e.g. "rgb(1e2%, 34.5%, .6%)"
- more clamping, e.g. "hsla(0, -10%, 120%, 1.5)"
- hue angle units, e.g. "turn", "rad"
- case-insensitive function names, e.g. "Rgb(0, 0, 0)"

This throws an error when mixing percentages and numbers, e.g. "rgb(100%, 128, 0%)".
This also changes the type of the errors from `ErrorException` to `ArgumentError`.
  • Loading branch information
kimikage committed May 29, 2021
1 parent 81aaf1e commit 6b437ab
Show file tree
Hide file tree
Showing 2 changed files with 132 additions and 85 deletions.
192 changes: 117 additions & 75 deletions src/parse.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,124 +5,144 @@ include("names_data.jl")
# Color Parsing
# -------------

const col_pat_hex = r"^\s*(#|0x)([[:xdigit:]]{3,8})\s*$"
const col_pat_rgb = r"^\s*rgb\(\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*\)\s*$"
const col_pat_hsl = r"^\s*hsl\(\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*\)\s*$"
const col_pat_rgba = r"^\s*rgba?\(\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*[,/]\s*((?:\d+|(?=\.\d))(?:\.\d*)?%?)\s*\)\s*$"
const col_pat_hsla = r"^\s*hsla?\(\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*[,\s]\s*(\d+%?)\s*[,/]\s*((?:\d+|(?=\.\d))(?:\.\d*)?%?)\s*\)\s*$"
const col_pat_func3 = r"^\s*(?:rgba?|hsla?)
\(\s*([^\s,/\)]+)\s*
[,\s]\s*([^\s,/\)]+)\s*
[,\s]\s*([^\s,/\)]+)\s*
(?:[,/]\s*([^\s,/\)]+)\s*)?\)\s*$"ix
const col_pat_hex = r"^\s*(?:#|0x)([[:xdigit:]]{3,8})\s*$"
const col_pat_unitful = r"^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(.*)$"i

chop1(x) = SubString(x, 1, lastindex(x) - 1) # `chop` is slightly slow

function parse_hex(hex::SubString{String}) # It is guaranteed to be a valid hex string.
digits = UInt32(0)
for d in codeunits(hex)
dl = d | 0x20
digits = (digits << 0x4) + dl - (dl < UInt8('a') ? UInt8('0') : UInt8('a') - 0xa)
end
return digits
end

function tryparse_dec(dec::SubString{String})
de = 0
for d in codeunits(dec)
dx = d - UInt8('0')
0x0 <= dx <= 0x9 || return nothing
de = de * 10 + dx
end
return de
end

function parse_f32(dec::SubString{String})
v = tryparse_dec(dec)
v === nothing && return parse(Float32, dec)
Float32(v)
end

# Parse a number used in the "rgb()" or "hsl()" color.
function parse_rgb(num::AbstractString)
if @inbounds num[end] == '%'
return N0f8(clamp(parse(Int, chop1(num), base=10) / 100, 0, 1))
else
v = clamp(parse(Int, num, base=10), 0, 255)
return reinterpret(N0f8, unsafe_trunc(UInt8, v))

function parse_rgb(num::SubString{String})
@inbounds num[end] == '%' && throw_rgb_unification_error()
v = tryparse_dec(num)
if v === nothing
v = round(Int, parse(Float32, num))
end
return reinterpret(N0f8, unsafe_trunc(UInt8, clamp(v, 0, 255)))
end

function parse_hsl_hue(num::AbstractString)
if @inbounds num[end] == '%'
error("hue cannot end in %")
else
return parse(Int, num, base=10)
function parse_rgb_pc(num::SubString{String})
@inbounds num[end] == '%' || throw_rgb_unification_error()
v = round(Int, parse_f32(chop1(num)) * 2.55f0)
return reinterpret(N0f8, unsafe_trunc(UInt8, clamp(v, 0, 255)))
end

function throw_rgb_unification_error()
throw(ArgumentError("RGB values should be unified in numbers in [0,255] or percentages."))
end

function parse_hue(num::SubString{String})
mat = match(col_pat_unitful, num)
if mat !== nothing
v0, unit0 = mat.captures
v = parse_f32(v0)
isempty(unit0) && return v
unit = lowercase(unit0)
if unit == "deg"
return v
elseif unit == "turn"
return v * 360f0
elseif unit == "rad"
return rad2deg(v)
elseif unit == "grad"
return v * 0.9f0
end
end
throw(ArgumentError("invalid hue notation: $num"))
end

function parse_hsl_sl(num::AbstractString)
function parse_hsl_pc(num::SubString{String})
if @inbounds num[end] != '%'
error("saturation and lightness must end in %")
else
return parse(Int, chop1(num), base=10) / 100
throw(ArgumentError("saturation and lightness must end in %"))
end
return clamp(parse_f32(chop1(num)) / 100f0, 0.0f0, 1.0f0)
end

# Parse a number used in the alpha field of "rgba()" and "hsla()".
function parse_alpha_num(num::AbstractString)
function parse_alpha(num::SubString{String})
if @inbounds num[end] == '%'
return parse(Int, chop1(num), base=10) / 100f0
v = parse_f32(chop1(num)) / 100f0
else
# `parse(Float32, num)` is somewhat slow on Windows(x86_64-w64-mingw32).
# However, the following has the opposite effect on Linux.
# m = match(r"0?\.(\d{1,9})", num)
# if m != nothing
# d = m.captures[1]
# return parse(Int, d, base=10) / Float32(exp10(length(d)))
# end
return parse(Float32, num)
v = parse(Float32, num)
end
return clamp(v, 0.0f0, 1.0f0)
end

function _parse_colorant(desc::String)
n0f8(x) = reinterpret(N0f8, unsafe_trunc(UInt8, x))
mat = match(col_pat_hex, desc)
if mat !== nothing
prefix, len = mat.captures[1], length(mat.captures[2])
digits = parse(UInt32, mat.captures[2], base=16)
len = ncodeunits(mat[1])
digits = parse_hex(mat[1])
if len == 6
return convert(RGB{N0f8}, reinterpret(RGB24, digits))
elseif len == 3
return RGB(n0f8((digits>>8) & 0xF * 0x11),
n0f8((digits>>4) & 0xF * 0x11),
n0f8((digits>>0) & 0xF * 0x11))
elseif len == 8
if prefix[1] == '0'
return ARGB{N0f8}(n0f8(digits>>16),
n0f8(digits>> 8),
n0f8(digits>> 0),
n0f8(digits>>24))
else
if occursin('#', desc)
return RGBA{N0f8}(n0f8(digits>>24),
n0f8(digits>>16),
n0f8(digits>> 8),
n0f8(digits>> 0))
else
return ARGB{N0f8}(n0f8(digits>>16),
n0f8(digits>> 8),
n0f8(digits>> 0),
n0f8(digits>>24))
end
elseif len == 4
if prefix[1] == '0'
return ARGB{N0f8}(n0f8((digits>> 8) & 0xF * 0x11),
n0f8((digits>> 4) & 0xF * 0x11),
n0f8((digits>> 0) & 0xF * 0x11),
n0f8((digits>>12) & 0xF * 0x11))
else
if occursin('#', desc)
return RGBA{N0f8}(n0f8((digits>>12) & 0xF * 0x11),
n0f8((digits>> 8) & 0xF * 0x11),
n0f8((digits>> 4) & 0xF * 0x11),
n0f8((digits>> 0) & 0xF * 0x11))
else
return ARGB{N0f8}(n0f8((digits>> 8) & 0xF * 0x11),
n0f8((digits>> 4) & 0xF * 0x11),
n0f8((digits>> 0) & 0xF * 0x11),
n0f8((digits>>12) & 0xF * 0x11))
end
end
end
mat = match(col_pat_rgb, desc)
if mat !== nothing
return RGB{N0f8}(parse_rgb(mat.captures[1]),
parse_rgb(mat.captures[2]),
parse_rgb(mat.captures[3]))
end

mat = match(col_pat_hsl, desc)
if mat !== nothing
T = ColorTypes.eltype_default(HSL)
return HSL{T}(parse_hsl_hue(mat.captures[1]),
parse_hsl_sl(mat.captures[2]),
parse_hsl_sl(mat.captures[3]))
end

mat = match(col_pat_rgba, desc)
if mat !== nothing
return RGBA{N0f8}(parse_rgb(mat.captures[1]),
parse_rgb(mat.captures[2]),
parse_rgb(mat.captures[3]),
parse_alpha_num(mat.captures[4]))
end

mat = match(col_pat_hsla, desc)
if mat !== nothing
T = ColorTypes.eltype_default(HSLA)
return HSLA{T}(parse_hsl_hue(mat.captures[1]),
parse_hsl_sl(mat.captures[2]),
parse_hsl_sl(mat.captures[3]),
parse_alpha_num(mat.captures[4]))
mat = match(col_pat_func3, desc)
if mat !== nothing #&& mat[1] !== nothing && mat[2] !== nothing && mat[3] !== nothing
if occursin(r"^\s*rgb"i, desc)
return _parse_colorant_rgb(mat[1], mat[2], mat[3], mat[4])
else # occursin(r"^\s*hsl"i, desc)
return _parse_colorant_hsl(mat[1], mat[2], mat[3], mat[4])
end
end

sdesc = strip(desc)
Expand All @@ -142,7 +162,29 @@ function _parse_colorant(desc::String)
return RGB{N0f8}(n0f8(c[1]), n0f8(c[2]), n0f8(c[3]))
end

error("Unknown color: ", desc)
throw(ArgumentError("Unknown color: $desc"))
end

function _parse_colorant_rgb(p1, p2, p3, alpha)
if @inbounds p1[end] == '%'
r, g, b = parse_rgb_pc(p1), parse_rgb_pc(p2), parse_rgb_pc(p3)
else
r, g, b = parse_rgb(p1), parse_rgb(p2), parse_rgb(p3)
end
if alpha === nothing
return RGB{N0f8}(r, g, b)
else
return RGBA{N0f8}(r, g, b, parse_alpha(alpha) % N0f8)
end
end

function _parse_colorant_hsl(p1, p2, p3, alpha)
h, s, l = parse_hue(p1), parse_hsl_pc(p2), parse_hsl_pc(p3)
if alpha === nothing
return typeof(HSL(0,0,0))(h, s, l)
else
return typeof(HSLA(0,0,0))(h, s, l, parse_alpha(alpha))
end
end

"""
Expand Down
25 changes: 15 additions & 10 deletions test/parse.jl
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Test, Colors
using FixedPointNumbers

@testset "Parse" begin
Expand All @@ -10,7 +11,7 @@ using FixedPointNumbers
@test redN0f8 === RGB{N0f8}(1,0,0)
@test parse(RGB{Float64}, "red") === RGB{Float64}(1,0,0)
@test isa(parse(HSV, "blue"), HSV)
@test_throws ErrorException parse(Colorant, "p ink")
@test_throws ArgumentError parse(Colorant, "p ink")
@test parse(Colorant, "transparent") === RGBA{N0f8}(0,0,0,0)
@test parse(Colorant, "\nSeaGreen ") === RGB{N0f8}(r8(0x2E),r8(0x8B),r8(0x57))
@test parse(Colorant, "sea GREEN") === colorant"seagreen"
Expand All @@ -23,29 +24,33 @@ using FixedPointNumbers
@test parse(Colorant, "0xFB0A") === ARGB(r8(0xBB),r8(0x00),r8(0xAA),r8(0xFF))
@test parse(Colorant, "#FFBB00AA") === RGBA(r8(0xFF),r8(0xBB),r8(0x00),r8(0xAA))
@test parse(Colorant, "0xFFBB00AA") === ARGB(r8(0xBB),r8(0x00),r8(0xAA),r8(0xFF))
@test_throws ErrorException parse(Colorant, "#BAD05")
@test_throws ErrorException parse(Colorant, "#BAD0007")
@test_throws ErrorException parse(Colorant, "#BAD000009")
@test_throws ArgumentError parse(Colorant, "#BAD05")
@test_throws ArgumentError parse(Colorant, "#BAD0007")
@test_throws ArgumentError parse(Colorant, "#BAD000009")

# rgb()
@test parse(Colorant, "rgb(55,217,127)") === RGB{N0f8}(r8(0x37),r8(0xd9),r8(0x7f))
@test colorant" rgb( 55, 217, 127 ) " === RGB{N0f8}(r8(0x37),r8(0xd9),r8(0x7f))
@test colorant" Rgb( 55, 217, 127 ) " === RGB{N0f8}(r8(0x37),r8(0xd9),r8(0x7f))
@test parse(Colorant, "rgb(22%,85%,50%)") === RGB{N0f8}(r8(0x38),r8(0xd9),r8(0x80))
@test parse(Colorant, "rgba(55,217,127,0.5)") === RGBA{N0f8}(r8(0x37),r8(0xd9),r8(0x7f),0.5)
@test parse(Colorant, "rgb( 55,217,127,50%)") === RGBA{N0f8}(r8(0x37),r8(0xd9),r8(0x7f),0.5) # CSS Color Module Level 4
@test parse(Colorant, "rgb( 55 217 127 /.5)") === RGBA{N0f8}(r8(0x37),r8(0xd9),r8(0x7f),0.5) # CSS Color Module Level 4
@test parse(Colorant, "rgb(55, 85%, 50%)") === RGB{N0f8}(r8(0x37),r8(0xd9),r8(0x80)) # this is invalid according to CSS spec.
@test_throws ErrorException parse(Colorant, "rgb(21.6%,85%,50%)") # this is valid but not supported
@test_throws ArgumentError parse(Colorant, "rgb(55, 85%, 50%)") # this is invalid according to CSS spec.
@test parse(Colorant, "rgb(21.6%,85%,50%)") === RGB{N0f8}(r8(0x37),r8(0xd9),r8(0x80))

# hsl()
@test parse(Colorant, "hsl(120, 100%, 50%)") === HSL{Float32}(120,1.0,.5)
@test colorant" hsl( 120, 100%, 50% ) " === HSL{Float32}(120,1.0,.5)
@test colorant" Hsl( 120, 100%, 50% ) " === HSL{Float32}(120,1.0,.5)
@test parse(RGB{N0f8},"hsl(120, 100%, 50%)") === convert(RGB{N0f8}, HSL{Float32}(120,1.0,.5))
@test_throws ErrorException parse(Colorant, "hsl(120, 100, 50)")
@test_throws ErrorException parse(Colorant, "hsl(120%,100%,50%)")
@test_throws ArgumentError parse(Colorant, "hsl(120, 100, 50)")
@test_throws ArgumentError parse(Colorant, "hsl(120%,100%,50%)")
@test parse(Colorant, "hsla(120,50%,7%, .6)") === HSLA{Float32}(120,.5,.07,.6)
@test parse(Colorant, "hsl( 120,50%,7%,60%)") === HSLA{Float32}(120,.5,.07,.6) # CSS Color Module Level 4
@test parse(Colorant, "hsl( 120 50% 7% / 1)") === HSLA{Float32}(120,.5,.07, 1) # CSS Color Module Level 4
@test parse(Colorant, "hsl( 90Deg, 100%, 0%)") === HSL{Float32}(90, 1, 0)
@test parse(Colorant, "hsl( 0.25turn, 120%, 0.0%)") === HSL{Float32}(90, 1, 0)
@test parse(Colorant, "hsl(1.57079633RAD, 100%, -10%)") === HSL{Float32}(90, 1, 0)
@test parse(Colorant, "hsl( 100grad, 100%, 0e2%)") === HSL{Float32}(90, 1, 0)

@test parse(Colorant, :red) === colorant"red"
@test_deprecated parse(Colorant, colorant"red") === colorant"red"
Expand Down

0 comments on commit 6b437ab

Please sign in to comment.