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

Relax color parser based on the current and upcoming CSS specs #478

Merged
merged 1 commit into from
Jun 4, 2021
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
4 changes: 2 additions & 2 deletions docs/src/constructionandconversion.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ RGB{N0f8}(1.0,0.0,0.0)
julia> colorant"rgba(255,0,0,0.6)" # with alpha in [0, 1]
RGBA{N0f8}(1.0,0.0,0.0,0.6)

julia> colorant"rgba(100%,80%,0%,0.6)" # with "integer" percentages
julia> colorant"rgba(100%,80%,0.0%,0.6)" # with percentages
RGBA{N0f8}(1.0,0.8,0.0,0.6)

julia> parse(ARGB, "rgba(255,0,0,0.6)") # you can specify the return type
Expand All @@ -82,7 +82,7 @@ ARGB{N0f8}(1.0,0.0,0.0,0.6)
julia> colorant"hsl(120, 100%, 25%)" # hsl() notation
HSL{Float32}(120.0f0,1.0f0,0.25f0)

julia> colorant"hsla(120, 100%, 25%, 0.6)" # hsla() notation
julia> colorant"hsla(120, 100%, 25%, 60%)" # hsla() notation
HSLA{Float32}(120.0f0,1.0f0,0.25f0,0.6f0)

julia> colorant"transparent" # transparent "black"
Expand Down
194 changes: 119 additions & 75 deletions src/parse.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,124 +5,146 @@ 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})
vi = tryparse_dec(num)
vi !== nothing && return Float32(vi)
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 +164,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
26 changes: 16 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,34 @@ 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.1, 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( 90.0, 100%, 0%)") === HSL{Float32}(90, 1, 0)
@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