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