lib/Mail/SpamAssassin/HTML/Color.pm (312 lines of code) (raw):
# <@LICENSE>
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to you under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at:
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# </@LICENSE>
package Mail::SpamAssassin::HTML::Color;
use strict;
use warnings;
no warnings 'numeric';
use Carp qw(croak);
use overload '""' => sub { shift->as_hex }, fallback => 1;
my %html_color = (
# HTML 4 defined 16 colors
aqua => [0, 255, 255],
black => [0, 0, 0],
blue => [0, 0, 255],
fuchsia => [255, 0, 255],
gray => [128, 128, 128],
green => [0, 128, 0],
lime => [0, 255, 0],
maroon => [128, 0, 0],
navy => [0, 0, 128],
olive => [128, 128, 0],
purple => [128, 0, 128],
red => [255, 0, 0],
silver => [192, 192, 192],
teal => [0, 128, 128],
white => [255, 255, 255],
yellow => [255, 255, 0],
# colors specified in CSS3 color module
aliceblue => [240, 248, 255],
antiquewhite => [250, 235, 215],
aquamarine => [127, 255, 212],
azure => [240, 255, 255],
beige => [245, 245, 220],
bisque => [255, 228, 196],
blanchedalmond => [255, 235, 205],
blueviolet => [138, 43, 226],
brown => [165, 42, 42],
burlywood => [222, 184, 135],
cadetblue => [95, 158, 160],
chartreuse => [127, 255, 0],
chocolate => [210, 105, 30],
coral => [255, 127, 80],
cornflowerblue => [100, 149, 237],
cornsilk => [255, 248, 220],
crimson => [220, 20, 60],
cyan => [0, 255, 255],
darkblue => [0, 0, 139],
darkcyan => [0, 139, 139],
darkgoldenrod => [184, 134, 11],
darkgray => [169, 169, 169],
darkgreen => [0, 100, 0],
darkkhaki => [189, 183, 107],
darkmagenta => [139, 0, 139],
darkolivegreen => [85, 107, 47],
darkorange => [255, 140, 0],
darkorchid => [153, 50, 204],
darkred => [139, 0, 0],
darksalmon => [233, 150, 122],
darkseagreen => [143, 188, 143],
darkslateblue => [72, 61, 139],
darkslategray => [47, 79, 79],
darkturquoise => [0, 206, 209],
darkviolet => [148, 0, 211],
deeppink => [255, 20, 147],
deepskyblue => [0, 191, 255],
dimgray => [105, 105, 105],
dodgerblue => [30, 144, 255],
firebrick => [178, 34, 34],
floralwhite => [255, 250, 240],
forestgreen => [34, 139, 34],
gainsboro => [220, 220, 220],
ghostwhite => [248, 248, 255],
gold => [255, 215, 0],
goldenrod => [218, 165, 32],
greenyellow => [173, 255, 47],
honeydew => [240, 255, 240],
hotpink => [255, 105, 180],
indianred => [205, 92, 92],
indigo => [75, 0, 130],
ivory => [255, 255, 240],
khaki => [240, 230, 140],
lavender => [230, 230, 250],
lavenderblush => [255, 240, 245],
lawngreen => [124, 252, 0],
lemonchiffon => [255, 250, 205],
lightblue => [173, 216, 230],
lightcoral => [240, 128, 128],
lightcyan => [224, 255, 255],
lightgoldenrodyellow => [250, 250, 210],
lightgray => [211, 211, 211],
lightgreen => [144, 238, 144],
lightpink => [255, 182, 193],
lightsalmon => [255, 160, 122],
lightseagreen => [32, 178, 170],
lightskyblue => [135, 206, 250],
lightslategray => [119, 136, 153],
lightsteelblue => [176, 196, 222],
lightyellow => [255, 255, 224],
limegreen => [50, 205, 50],
linen => [250, 240, 230],
magenta => [255, 0, 255],
mediumaquamarine => [102, 205, 170],
mediumblue => [0, 0, 205],
mediumorchid => [186, 85, 211],
mediumpurple => [147, 112, 219],
mediumseagreen => [60, 179, 113],
mediumslateblue => [123, 104, 238],
mediumspringgreen => [0, 250, 154],
mediumturquoise => [72, 209, 204],
mediumvioletred => [199, 21, 133],
midnightblue => [25, 25, 112],
mintcream => [245, 255, 250],
mistyrose => [255, 228, 225],
moccasin => [255, 228, 181],
navajowhite => [255, 222, 173],
oldlace => [253, 245, 230],
olivedrab => [107, 142, 35],
orange => [255, 165, 0],
orangered => [255, 69, 0],
orchid => [218, 112, 214],
palegoldenrod => [238, 232, 170],
palegreen => [152, 251, 152],
paleturquoise => [175, 238, 238],
palevioletred => [219, 112, 147],
papayawhip => [255, 239, 213],
peachpuff => [255, 218, 185],
peru => [205, 133, 63],
pink => [255, 192, 203],
plum => [221, 160, 221],
powderblue => [176, 224, 230],
rosybrown => [188, 143, 143],
royalblue => [65, 105, 225],
saddlebrown => [139, 69, 19],
salmon => [250, 128, 114],
sandybrown => [244, 164, 96],
seagreen => [46, 139, 87],
seashell => [255, 245, 238],
sienna => [160, 82, 45],
skyblue => [135, 206, 235],
slateblue => [106, 90, 205],
slategray => [112, 128, 144],
snow => [255, 250, 250],
springgreen => [0, 255, 127],
steelblue => [70, 130, 180],
tan => [210, 180, 140],
thistle => [216, 191, 216],
tomato => [255, 99, 71],
turquoise => [64, 224, 208],
violet => [238, 130, 238],
wheat => [245, 222, 179],
whitesmoke => [245, 245, 245],
yellowgreen => [154, 205, 50],
);
sub new {
my ($class, $color) = @_;
my $self = [];
bless $self, $class;
croak("Color value is required") unless (defined $color);
$color =~ s/^\s+|\s+$//g; # Trim whitespace
$color = lc($color);
# If color is 'transparent', set all values to 0
if ($color eq 'transparent') {
@$self = (0, 0, 0, 0);
return $self;
}
# Check if color is a named color
if (exists $html_color{$color}) {
@$self = (@{ $html_color{$color} }, 1);
return $self;
}
# Check if color is in hexadecimal format (#000 or #aabbcc)
if ($color =~ /^#([0-9a-f]{3}|[0-9a-f]{6})$/) {
my $hex = length($1) == 3
? join('', map { $_ x 2 } split //, $1)
: $1;
@$self = map { hex($_) } $hex =~ /../g;
push @$self, 1;
return $self;
}
# Check if color is in RGB format (rgb(255, 0, 153) or rgb(255 0 153 / 80%))
if ($color =~ /^rgba?\s*\((.*)\)$/) {
my @args = split(/[ ,\/]+/, $1);
push @args, 1 if @args == 3;
croak("Invalid number of arguments for RGB color") unless @args == 4;
for (@args) {
croak("Invalid RGB value: $_") unless $_ =~ /^(?:none|[+-]?\d*\.?\d+%?)$/;
}
my ($r, $g, $b) = map {
/^(.*)%$/ ? _round($1 * 255 / 100) : $_ + 0;
} @args[0..2];
$a = $args[3];
$a = $a =~ s/%$// ? $a / 100 : $a + 0;
@$self = ($r, $g, $b, $a);
return $self;
}
# Check if color is in HSL format (hsl(360, 100%, 50%) or hsl(360 100% 50% / 80%))
if ($color =~ /^hsla?\s*\((.*)\)$/) {
my @args = split(/[ ,\/]+/, $1);
push @args, 1 if @args == 3;
croak("Invalid number of arguments for HSL color") unless @args == 4;
for (@args[1..3]) {
croak("Invalid HSL value: $_") unless $_ =~ /^(?:none|[+-]?\d*\.?\d+%?)$/;
}
my ($h, $s, $l, $a) = @args;
$h = _parse_angle($h);
$s =~ s/%$//; $s /= 100;
$l =~ s/%$//; $l /= 100;
$a = $a =~ s/%$// ? $a / 100 : $a + 0;
@$self = _hsl_to_rgb($h, $s, $l);
push @$self, $a;
return $self;
}
# Check if color is in HWB format (hwb(240, 100%, 0%) or hwb(240 100% 0% / 80%))
if ($color =~ /^hwba?\s*\((.*)\)$/) {
my @args = split(/[ ,\/]+/, $1);
push @args, 1 if @args == 3;
croak("Invalid number of arguments for HWB color") unless @args == 4;
for (@args[1..3]) {
croak("Invalid HWB value: $_") unless $_ =~ /^(?:none|[+-]?\d*\.?\d+%?)$/;
}
my ($h, $wh, $bl, $a) = @args;
$h = _parse_angle($h);
$wh =~ s/%$//; $wh /= 100;
$bl =~ s/%$//; $bl /= 100;
$a = $a =~ s/%$// ? $a / 100 : $a + 0;
@$self = _hwb_to_rgb($h, $wh, $bl);
push @$self, $a;
return $self;
}
croak("Unsupported color format: $color");
}
sub blend {
my ($self, $background) = @_;
my ($r, $g, $b, $a) = @$self;
return $self if $a == 1;
my ($br, $bg, $bb) = @$background;
my $new_r = _round(($r * $a) + ($br * (1 - $a)));
my $new_g = _round(($g * $a) + ($bg * (1 - $a)));
my $new_b = _round(($b * $a) + ($bb * (1 - $a)));
@$self = ($new_r, $new_g, $new_b, 1);
return $self;
}
sub distance {
my ($self, $other_color) = @_;
my ($r1, $g1, $b1) = @$self[0..2];
my ($r2, $g2, $b2) = @$other_color[0..2];
my $r = ($r1 - $r2);
my $g = ($g1 - $g2);
my $b = ($b1 - $b2);
# geometric distance weighted by brightness
# maximum distance is 191.151823601032
my $distance = ((0.2126 * $r)**2 + (0.7152 * $g)**2 + (0.0722 * $b)**2)**0.5;
return $distance;
}
sub as_hex {
my ($self) = @_;
my ($r, $g, $b) = @$self[0..2];
return sprintf("#%02x%02x%02x", $r, $g, $b);
}
sub as_array {
my ($self) = @_;
return @$self;
}
#
# Static Private Methods
#
sub _hsl_to_rgb {
my ($hue, $saturation, $lightness) = @_;
# Ensure the hue is between 0-360 degrees and S, L are between 0 and 1
$hue %= 360;
$saturation = 0 if $saturation < 0;
$lightness = 0 if $lightness < 0;
$saturation = 1 if $saturation > 1;
$lightness = 1 if $lightness > 1;
my $c = (1 - abs(2 * $lightness - 1)) * $saturation;
my $x = $c * (1 - abs(($hue / 60) % 2 - 1));
my $m = $lightness - $c / 2;
my @rgb;
if ($hue >= 0 && $hue < 60) {
@rgb = ($c, $x, 0);
} elsif ($hue >= 60 && $hue < 120) {
@rgb = ($x, $c, 0);
} elsif ($hue >= 120 && $hue < 180) {
@rgb = (0, $c, $x);
} elsif ($hue >= 180 && $hue < 240) {
@rgb = (0, $x, $c);
} elsif ($hue >= 240 && $hue < 300) {
@rgb = ($x, 0, $c);
} else {
@rgb = ($c, 0, $x);
}
return map { _round(($_ + $m) * 255) } @rgb;
}
sub _hwb_to_rgb {
my ($hue, $whiteness, $blackness) = @_;
# Ensure the hue is between 0-360 degrees and W, B are between 0 and 1
$hue %= 360;
$whiteness = 0 if $whiteness < 0;
$blackness = 0 if $blackness < 0;
$whiteness = 1 if $whiteness > 1;
$blackness = 1 if $blackness > 1;
# Convert hue to range 0 to 1
my $h = $hue / 60; # Divide hue by 60 to put it in [0, 6)
my $f = $h - int($h);
my @rgb_base;
# Get RGB base values based on hue
if ($h < 1) { @rgb_base = (1, $f, 0); }
elsif ($h < 2) { @rgb_base = (1 - $f, 1, 0); }
elsif ($h < 3) { @rgb_base = (0, 1, $f); }
elsif ($h < 4) { @rgb_base = (0, 1 - $f, 1); }
elsif ($h < 5) { @rgb_base = ($f, 0, 1); }
else { @rgb_base = (1, 0, 1 - $f); }
# Apply whiteness and blackness to compute final RGB values
my $i = 1 - $whiteness - $blackness;
my @rgb = map { _round(($whiteness + $i * $_) * 255) } @rgb_base;
return @rgb;
}
sub _parse_angle {
my ($angle) = @_;
croak("Invalid color angle: $angle") unless $angle =~ /^(?:none|[+-]?\d*\.?\d+(?:deg|grad|rad|turn)?)$/;
$angle = $angle =~ s/deg$// ? $angle
: $angle =~ s/grad$// ? $angle * 360 / 400
: $angle =~ s/rad$// ? $angle * 180 / 3.14159
: $angle =~ s/turn$// ? $angle * 360
: $angle;
return _round($angle) % 360;
}
sub _round {
my ($value) = @_;
return int($value + 0.5);
}
1;
__END__
=head1 NAME
Mail::SpamAssassin::HTML::Color - A class to parse and manipulate CSS color values
=head1 SYNOPSIS
use Mail::SpamAssassin::HTML::Color;
my $color = Mail::SpamAssassin::HTML::Color->new('rgba(255, 0, 153, 0.5)');
$color->blend([255, 255, 255]);
my $distance = $color->distance([0, 0, 0]);
print "$color"; # Outputs the color as a hex string
=head1 DESCRIPTION
This class provides methods to parse various CSS color formats, blend them with a background color, calculate the distance between two colors, and convert the color to a hex string.
=head1 METHODS
=head2 new($color)
Creates a new color object from a CSS color string.
=head2 blend($background)
Blends the color with the given background color. Modifies the color in-place and returns the modified object.
=head2 distance($other_color)
Calculates the distance between the current color and another color using a brightness-weighted geometric formula.
=head2 as_hex
Returns the color as a hex string with a leading '#'.
=head2 as_array
Returns the color as an array of RGB values.
=cut