From d6bb85ec8e7d44dc1c5acad8df66a5a669135338 Mon Sep 17 00:00:00 2001 From: kimikage Date: Sun, 30 May 2021 03:30:20 +0900 Subject: [PATCH] Relax color parser based on the current and upcoming CSS specs 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`. --- docs/src/constructionandconversion.md | 4 +- src/parse.jl | 194 ++++++++++++++++---------- test/parse.jl | 26 ++-- 3 files changed, 137 insertions(+), 87 deletions(-) diff --git a/docs/src/constructionandconversion.md b/docs/src/constructionandconversion.md index 8d828f57..2cb6c176 100644 --- a/docs/src/constructionandconversion.md +++ b/docs/src/constructionandconversion.md @@ -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 @@ -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" diff --git a/src/parse.jl b/src/parse.jl index 2789ddc4..000d657a 100644 --- a/src/parse.jl +++ b/src/parse.jl @@ -5,62 +5,107 @@ 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 @@ -68,61 +113,38 @@ function _parse_colorant(desc::String) 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) @@ -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 """ diff --git a/test/parse.jl b/test/parse.jl index 6cbf4610..e502282e 100644 --- a/test/parse.jl +++ b/test/parse.jl @@ -1,3 +1,4 @@ +using Test, Colors using FixedPointNumbers @testset "Parse" begin @@ -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" @@ -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"