#! /usr/bin/perl

=head1 NAME

epubcssfix -- Make corrections to CSS in .EPUB files for better readability


=head1 SYNOPSIS

  # Make corrections to example.epub with the default options and write to 
  # example_fixed.epub
  epubcssfix example.epub

  # The same, but chatty
  epubcssfix --verbose example.epub

  # Put the unmodified example.epub at example_orig.epub and the modified version
  # at example.epub
  epubcssfix --replace example.epub

  # Remove all color: and background-color: attributes from example.epub's CSS
  epubcssfix --nocolors example.epub 

  # Take 10pt as the baseline font-size which is mapped to 100%. (So 12pt would be
  # 120%, 24pt would be 240%, etc.)
  epubcssfix --fontsize 10 example.epub

  # Remove colors from CSS and append '[removed colors]' to the epub title
  epubcssfix --nocolors --title '[removed colors]' example.epub 

  # Fix all the EPUB files in a directory and its subdirectories
  epubcssfix --recurse '/home/username/Calibre Library'

  # Fix all the EPUB files only in the target directory with no recursion
  epubcssfix /home/username/ebooks/authorname

  # Fix multiple specified EPUB files
  epubcssfix example.epub tinyfont.epub nocontrast.epub

  # Get brief help on command line options
  epubcssfix --help


=head1 DESCRIPTION

Many epubs come with unprofessional CSS that will not display correctly on some
ebook readers.  For instance, the font size may be illegibly small on a mobile
device, or the user may have dark mode turned on, but the CSS specifies element
colors according to an assumed (but not specified) white background, so there is
little or no contrast with the actual black background.

This script will take each listed epub, check if it has problematic color: or
font-size: elements in its CSS, and correct or remove them.  If it makes any
corrections, it will write the corrected epub to E<lt>filenameE<gt>_fixed.epub (unless
the -r or --replace option is specified).

Default corrections currently consist of supplying a contrasting background
color whenever a foreground color is specified without one, altering font sizes
specified in pt to percentages of default font size (based on 12pt = 100%), and
removing font sizes specified with other absolute units (cm, mm, in, px, pc).

=head1 OPTIONS

=over

=item -h  --help                     	

Display a brief help message.

=item -R  --recurse                   

Recurse through any directories found on the command line.

=item -v  --verbose                  	

Chatter about what files we're examining and what fixes we're making.

=item -r  --replace                  	

Put the original epub at E<lt>filenameE<gt>_orig.epub and the correction at
E<lt>filenameE<gt>.epub.

=item -n  --nocolors                 	

Strip out color attributes instead of supplying contrasting background colors.

=item -d  --darkmode                  

Add a black background color and invert all existing colors.

=item -F --filesuffix                    

Add this string to the output filename instead of '_fixed'.

=item -t  --title                     

Suffix the following string to the epub's title.  This can help keep the
different versions of an epub distinct in your ebook reader, if you're trying
out different options before deciding on a final version to keep.

=item -f  --fontsize                  

Baseline font size in pt that maps to 100%.  E.g. if you set --fontsize 10 then
a font-size of 10pt will be mapped to 100%, 12pt will be mapped to 120%, and
so on.  Default is 12.

=back



=head1 DEPENDENCIES

This script uses C<Archive::Zip> to access the files within an epub,
C<Graphics::ColorUtils> to map CSS/HTML color names to RGB values, both from
CPAN.  It also uses C<DirwalkCallback>, supplied with this script, and
C<Pod::Usage> and C<Getopt::Long>, from the standard Perl library.


=head1 LIMITATIONS

This script doesn't fully parse the CSS; it just uses regular expressions to
check for certain bad patterns I've noticed in epubs (mostly indie ebooks
formatted by the author with whatever tools they have on hand).  It does not
check the CSS for syntax errors, and doesn't fully check that color: values are
valid (e.g., C<color: rgb( 257, 0, 0 );> would slip past it).


=head1 CHANGELOG

=over

=item 2023-09-12

Add --darkmode and --filesuffix options.

=item 2023-09-08

Fixed logic errors in the complementary color code.  Fixed bug where elements
with greyscale foreground colors were getting background colors that barely
contrasted with them or not at all.  Now, if a color is greyish (defined as
having a difference of no more than 32 between its highest and lowest r,g,b
values), we average the r,g,b and return white if it's less than 128 and black
if it's more than 128.  Otherwise, use the (revised and corrected) complementary
color function.

=back


=head1 AUTHOR

Jim Henry III, L<http://jimhenry.conlang.org/software/>

=head1 LICENSE

This library is free software; you may redistribute it and/or modify it under
the same terms as Perl itself.

=cut

use warnings;
use strict;
use v5.14;
use Archive::Zip qw( :ERROR_CODES :CONSTANTS );
use Graphics::ColorUtils qw( :names hls2rgb );
use Getopt::Long;
use Pod::Usage;
use DirwalkCallback;

my $version = "0.9"; # used by help()

# set by GetOptions() in main and used in various functions
my $verbose = 0;     
my $baseline_font_size = 12;
my $file_suffix = "_fixed";

=head1 FUNCTIONS

=head2 help()

Print brief help.

=cut

sub help {
	my $name = $0;
	$name =~ s(.*/)();
	print<<HELP;

$name version $version

http://jimhenry.conlang.org/software/

Many epubs come with unprofessional CSS that will not display correctly on some
ebook readers.  For instance, the font size may be illegibly small on a mobile
device, or the user may have dark mode turned on, but the CSS specifies element
colors according to an assumed (but not specified) white background.

This script will take each listed epub, check if it has problematic color: or
font-size: elements in its CSS, and correct or remove them.  If it makes any
corrections, it will write the corrected epub to <filename>_fixed.epub (unless
the -r or --replace option is specified).

Default corrections currently consist of supplying a complementary background
color whenever a foreground color is specified without one, altering font sizes
specified in pt to percentages of default font size (based on 12pt = 100%), and
removing font sizes specified with other absolute units (cm, mm, in, px, pc).

Usage:
$name [options] [epub filenames | directory names] 

-R  --recurse                   Recurse through any directories found on the command
                                line.

-v  --verbose                  	Chatter about what files we're examining and what 
                                fixes we're making.

-r  --replace                  	Put the original epub at <filename>_orig.epub 
                                and the correction at <filename>.epub.

-F --filesuffix                 Add this string to the output filename instead of
                                '_fixed'.

-n  --nocolors                 	Strip out color attributes instead of supplying
                                complementary background colors.

-d  --darkmode                  Add a black background color and invert all existing
                                colors.

-t  --title                     Suffix the following string to the epub's title.
                                This can help keep the different versions of an
                                epub distinct in your ebook reader, if you're
                                trying out different options before deciding
                                on a final version to keep.

-f  --fontsize                  Baseline font size in pt that maps to 100%.
                                E.g. if you set this --fontsize 10 then a font-size
                                of 10pt will be mapped to 100% and 12pt will be
                                mapped to 120%, and so on.  Default is 12.

-h  --help                     	Display this message.

-m  --manual                    Get the complete documenation.

HELP

}


=head2 complement( rgb array )

Calculate the complementary color and return another rgb array.

=cut 


# https://stackoverflow.com/questions/36906252/difference-between-complement-and-invert-in-sass/36908191#36908191
sub complement(@) {
    
    my ($r, $g, $b) = @_;
    my @rgb = @_;
    my $max = 0;
    my $min = 255;
    for ( @rgb ) {
	$max = $_ if $_ > $max;
	$min = $_ if $_ < $min;
    }
    my $spread = $max + $min;
    return ( $spread - $r, $spread - $g, $spread - $b );
}

=head2 rgb2hex( rgb array )

Convert from an rgb array to a six-digit HTML/CSS hex color string.

=cut 


sub rgb2hex(@) {
    my ($r, $g, $b) = @_;
    my $n = $r * 65536 + $g * 256 + $b;
    return sprintf "#%06x", $n;
}

=head2 hex2rgb( hex color string )

Convert from a six-digit HTML/CSS hex color string to an rgb array.

=cut 

sub hex2rgb($) {
    my $hexstr = shift;
    $hexstr =~ s/#//;
    my @rgb = map { hex } $hexstr =~ m/ ( [[:xdigit:]] {2} ) /gx;
}


sub print_rgb(@) {
    my ($r, $g, $b) = @_;
    print "red $r, green $g, blue $b\n";
}

=head2 complementary_color( CSS color string )

Take any of the valid types of CSS color value and return the 
complementary color (as the same type of CSS color value if possible,
otherwise a hex string).

There's a lot of duplicate code between this and css2rgb(). I'm not
sure we can fix that unless we give up on mirroring the type of CSS color
value the color: had in the background-color: we supply.

=cut 

sub complementary_color {
    # CSS is case-insensitive, except for a few things like font-family.
    my $color = lc shift;
    die "Missing argument to complementary_color()" unless $color;

    state $color_names;
    if ( not $color_names ) {
	#set_default_namespace("www");
	$color_names = available_names();
    }
    $color =~ s/^\s+//;
    $color =~ s/\s+$//;
    
    if ( $color =~ /^#[[:xdigit:]]{3}$/ ) {
	
	my $sixdigit = $color;
	$sixdigit =~ s/([[:xdigit:]])/$1$1/g;
	my @rgb = hex2rgb( $sixdigit ); 
	my @complement_rgb = complement( @rgb );
	my $hexstr = rgb2hex( @complement_rgb );
	return $hexstr;	    	    

    } elsif ( $color =~ /^#[[:xdigit:]]{6}$/ ) {

	return rgb2hex( complement( hex2rgb( $color ) ) );
	
    } elsif ( $color =~ /rgb \s* \( \s* ([0-9]+) \s* , \s* ([0-9]+) , \s* ([0-9]+) \s* \) /x ) {

	my ($cr, $cg, $cb) = complement( $1, $2, $3 );
	return "rgb( $cr, $cg, $cb )";

    } elsif ( $color =~ /rgba \s* \( \s* ([0-9]+) \s* , \s* ([0-9]+) , \s* ([0-9]+) \s* , \s* ([0-9.]+) \s* \) /x ) {

	my ($r, $g, $b, $alpha) = ($1, $2, $3, $4);
	my ($cr, $cg, $cb) = complement( $r, $g, $b );
	return sprintf "rgba( %d, %d, %d, %s )", $cr, $cg, $cb, $alpha;

    } elsif ( $color =~ /hsl \s* \( \s*  ([0-9]+) \s* , \s* ([0-9]+)% , \s* ([0-9]+)% \s* \) /x ) {

	my ( $hue, $saturation, $lightness ) = ($1, $2, $3);
	my $hue2 = ($hue + 180) % 360;
	return sprintf "hsl( %d, %d%%, %d%% )", $hue2, $saturation, $lightness;

    } elsif ( $color =~ /hsla \s* \( \s*  ([0-9]+) \s* , \s* ([0-9]+)% , \s* ([0-9]+)% \s* , \s* ([0-9.]+) \s* \) /x ) {
	
	my ( $hue, $saturation, $lightness, $alpha ) = ($1, $2, $3, $4);
	my $hue2 = ($hue + 180) % 360;
	return sprintf "hsl( %d, %d%%, %d%%, %0.2f )", $hue2, $saturation, $lightness, $alpha;
	
    } elsif ( $color =~ /currentcolor/i ) {

	warn "Should have removed currentcolor in fix_css_colors()";

    } elsif ( $color =~ /inherit/i ) {

        return "inherit";

    } elsif ( $color_names->{ "www:". $color} or $color_names->{ $color} ) {

	my $hexcolor = name2rgb( $color );
	if ( not $hexcolor ) {
	    $hexcolor = name2rgb( "www:" . $color );
	    if ( not $hexcolor ) {
		die "Can't resolve color name $color";
	    }
	}
	    
	return rgb2hex( complement( hex2rgb( $hexcolor ) ) );

    }  else  {
	die "Color format not implemented: $color";
    }
}

=head2 css2rgb( CSS color string )

Take any of the valid types of CSS color value and return 
an rgb array.

=cut 

sub css2rgb {
    # CSS is case-insensitive, except for a few things like font-family.
    my $color = lc shift;
    die "Missing argument to css2rgb()" unless $color;

    state $color_names;
    if ( not $color_names ) {
	#set_default_namespace("www");
	$color_names = available_names();
    }
    $color =~ s/^\s+//;
    $color =~ s/\s+$//;
    
    if ( $color =~ /^#[[:xdigit:]]{3}$/ ) {
	
	my $sixdigit = $color;
	$sixdigit =~ s/([[:xdigit:]])/$1$1/g;
	my @rgb = hex2rgb( $sixdigit );
	return @rgb;

    } elsif ( $color =~ /^#[[:xdigit:]]{6}$/ ) {

	return hex2rgb( $color );
	
    } elsif ( $color =~ /rgb \s* \( \s* ([0-9]+) \s* , \s* ([0-9]+) , \s* ([0-9]+) \s* \) /x ) {

	return ( $1, $2, $3 );

    } elsif ( $color =~ /rgba \s* \( \s* ([0-9]+) \s* , \s* ([0-9]+) , \s* ([0-9]+) \s* , \s* ([0-9.]+) \s* \) /x ) {

	return ($1, $2, $3);

    } elsif ( $color =~ /hsl \s* \( \s*  ([0-9]+) \s* , \s* ([0-9]+)% , \s* ([0-9]+)% \s* \) /x ) {

	my ( $hue, $saturation_pct, $lightness_pct ) = ($1, $2, $3);
	my @rgb = hls2rgb( $hue, $lightness_pct/100, $saturation_pct/100 );
	return @rgb;	

    } elsif ( $color =~ /hsla \s* \( \s*  ([0-9]+) \s* , \s* ([0-9]+)% , \s* ([0-9]+)% \s* , \s* ([0-9.]+) \s* \) /x ) {
	
	my ( $hue, $saturation_pct, $lightness_pct, $alpha ) = ($1, $2, $3, $4);
	my @rgb = hls2rgb( $hue, $lightness_pct/100, $saturation_pct/100 );
	return @rgb;	
    } elsif ( $color =~ /inherit|currentcolor|transparent/i ) {

	warn "Can't convert '$color' to rgb";
	return $color;

    } elsif ( $color_names->{ "www:". $color} or $color_names->{ $color} ) {

	my $hexcolor = name2rgb( $color );
	if ( not $hexcolor ) {
	    $hexcolor = name2rgb( "www:" . $color );
	    if ( not $hexcolor ) {
		die "Can't resolve color name $color";
	    }
	}
	return hex2rgb( $hexcolor );

    }  else  {
	die "Color format not implemented: $color";
    }
}


=head2 inverse_color( CSS color value )

Take a CSS color value and return an inverse color as six-digit hex string.

=cut

sub inverse_color {
    my $color = shift;
    my @rgb = css2rgb( $color );
    if ( $rgb[0] !~ m/^[0-9]+$/ ) {
	# it was a color like 'inherit' or 'transparent' that can't be converted to rgb
	return $color;
    }
    my @rgb_inverse = map { 255 - $_ } @rgb;
    my $hexstr = rgb2hex( @rgb_inverse );
    print "calculated inverse $hexstr from $color\n"  if $verbose;
    return $hexstr;
}


=head2 average( list )

Average a list of numbers.  Used by contrasting_color().

=cut

sub average(@) {
    my $total = 0;
    for my $i ( @_ ) {
	$total += $i;
    }
    return ( $total / scalar @_ );
}

=head2 contrasting_color( CSS color string )

Test whether a CSS color is greyish (all the rgb values are fairly close
together if not equal), and return black or white depending on how dark the grey
is.  Otherwise, return the complementary color.  (The complement of a greyish
color tends to be equally greyish and too low-contrast.)  This is an imperfect
algorithm because often a color can be far from greyish and still its
complementary color doesn't look good in contrast to it.  If that happens with a
given epub, it's probably best to run with --nocolors.

=cut

sub contrasting_color($) {
    my $color = shift;
    my @rgb = css2rgb( $color );
    # test if all three of rgb are fairly close in value; if so, complementary_color will
    # return a low or zero contrast background color.  Use a different strategy in that case.

    my %diffs;
    for ( my $c1 = 0; $c1 <= 2; $c1++ ) {
        for ( my $c2 = 0; $c2 <= 2; $c2++ ) {
	    next if $c1 == $c2;
	    $diffs{$c1.":".$c2} = abs( $rgb[$c1] - $rgb[$c2] );
	}
    }

#    print "diffs:\n";
#    print join ", ", map { "$_ ~ $diffs{$_}" } keys %diffs;
#    print "\n";
    
    if ( not grep { $diffs{$_} > 32 } keys %diffs ) {
	# all the r g b values are fairly close together, more or less grey.
	# so complmentary_color won't work.
	my $avg = average( @rgb );
	if ( $avg > 0x80 ) {
	    return "#000000";
	} else {
	    return "#ffffff";
	}
    } else {
	return complementary_color( $color );
    }
}


sub run_test {
    my @test_data = (
#	"#000000",
#	"#ffffff",
#	"#808080",
#	"#708580",
#	"#90a090",
#	"#F3F",
#	"#FF0080",
#	"teal",
	"rgb( 255, 0, 64)",
	"rgba(0,64,0,0.8)",
	"hsl(90, 100%, 30%)",
	"hsla(160, 80%, 80%, 0.9)",
	);

    print "color \t\t css2rgb()\n";
    for ( @test_data ) {
	print $_, "\t\t";
	print_rgb css2rgb( $_ ), "\n";
    }

#    print "color \t\t contrasting_color()\n";
#    for ( @test_data ) {
#	print $_, "\t\t";
#	print contrasting_color( $_ ), "\n";
#    }
}

=head2 fix_css_colors( css text, css filename, epub filename )

Takes a string representing the contents of a CSS file or a <style>
element, plus filename context for debugging purposes, and returns 
a corrected version of the CSS or undef if no changes were needed.

Basically it supplies a contrasting background color
for those elements where there are is only a foreground color.

Need to account for the rarer case where the background color is
specified but the foreground color is not.  This is the case with
Programming Perl.epub:

tr:nth-of-type(even) {
  background-color: #f1f6fc;
}

=cut

sub fix_css_colors {
    my ($csstext, $css_fn, $epub_fn) = @_;
    return if not $csstext;
    my $errors = 0;
    my $corrections = 0;
    my $printed_filename = 0;

    say "Checking $epub_fn:$css_fn for bad colors\n" if $verbose;
    
    # this might be a good use of negative lookbehind?
    my @css_blocks = split /(})/, $csstext;
    for my $block ( @css_blocks ) {
	if ( $block =~ m/\b color: \s* ( [^;]+ ) \s* (?:;|$) /xi ) {
	    my $fgcolor = $1;
	    print "found color: $fgcolor\n" if $verbose;

	    if ( $fgcolor =~ m/currentcolor/i ) {
		$block =~ s/(color: \s* currentcolor \s* ;? \s* ) \n* //xi;
		print "Stripping out $1 as it is a pleonasm\n"  if $verbose;
		$corrections++;
		next;
	    }
	    
	    if ( $block !~ m/background-color:/ ) {
		my $bgcolor = contrasting_color( $fgcolor );
		$block =~ s/(color: \s* [^;}]+ \s* (?:;|$) )/background-color: $bgcolor;\n$1/xi;
		print "corrected block:\n$block\n}\n" if $verbose;
   	        $corrections++;
	    }
	}
    }

    if ( $corrections ) {
	my $new_css_text = join "", @css_blocks;
	return $new_css_text;
    } else {
	return undef;
    }    
}

=head2 force_darkmode( CSS text, CSS filename, EPUB filename )

Set the body { background-color: black } and invert all the color:
elements found in CSS.

=cut

sub force_darkmode {
    my ($csstext, $css_fn, $epub_fn) = @_;
    return if not $csstext;
    print "forcing darkmode on $epub_fn/$css_fn\n";

#    my $corrections = 0;
    my $body_idx = -1;
#    my $body_found = 0;
    my @blocks = split /([{}])/, $csstext;

    # after splitting on brackets, the elements of the list will be like:
    # 0        1             2                  3
    # selector open bracket  properties block  close bracket
    # and so on for elements 4-7, etc.

    # Thus the $i % 4 test in the if below -- if it's zero, then we know we're in
    # a selector and we need to check for body.

    
    for my $i ( 0 .. $#blocks ) {
	if ( $i % 4 == 0 && $blocks[$i] =~ /\bbody\b/ ) {
	    $body_idx = $i;
	    next;
	}
	next if $i %4 != 2;  # skip unless we're in a "property: value;" block
	
	if ( $blocks[$i] =~ m/\bcolor:/xi ) {
	    # matches both background-color: and color:
	    $blocks[$i] =~ s/color: ( [^;]+ ) /"color: " . inverse_color( $1 ) /exi;
	    print "inverted color in block: \n" . $blocks[$i] . "\n"   if $verbose;
	}

	if ( $body_idx == $i - 2 ) {
	    # last list element was a bracket, the one before that was the 'body' selector
    	    unless ( $blocks[$i] =~ m/background-color:/i ) {
		# before we add a background-color, we need to make sure the last
		# statement in the block has as semicolon after it and add one if not.
		unless ( $blocks[$i] =~ /; \s* $/x ) {
		    $blocks[$i] .= ';';
		}
		$blocks[$i] .= "\n background-color: #000000;\n";
		print "added background-color to exisiting body: \n" . $blocks[$i] . "\n"   if $verbose;
	    }
	}
    }

    unless ( $body_idx > -1 ) {
	push @blocks, "\n body { background-color: #000000; }\n";
	print "added new body block with black background-color: \n"   if $verbose;
    }

    # join the blocks and return the revised CSS
    my $new_css = join '', @blocks;
}



=head2 remove_css_colors( CSS text, CSS filename, EPUB filename )

Return corrected CSS text with all color: and background-color: attributes
removed, or undef if there were no color or background-color: attributes found.

=cut


sub remove_css_colors {
    my ($csstext, $css_fn, $epub_fn) = @_;
    return if not $csstext;

    my $matches = ( $csstext =~ s/(?:background-)?color: [^;]+ \s* ;*//xgi );
    if ( $matches ) {
	say "Removed $matches color or background-color attributes" if $verbose;
	return $csstext;
    } else {
	return undef;
    }
}

=head2 corrected_font_size( font-size string ) 

Look for absolute font size units (pt, px, pc, in, cm, mm) and correct them.
Adjust pt sizes to percentages and remove any other absolute font sizes.
Return the corrected font-size string (which may be empty.)

=cut

sub corrected_font_size {
    my $fontsize = $1;
    $fontsize =~ m/font-size: \s* ([0-9]+) (?:\.[0-9]+)? ([cm]m|p[xtc]|in)/x;
    my ($size, $unit) = ($1, $2);
    if ( not $unit ) {	
	# didn't match one of the absolute font size units, so it's already a
	# relative font-size, so it's fine	
	return $fontsize;
    } elsif ( $unit ne "pt" ) {
	# cm, mm, px and pc don't have a clear mapping to relative font sizes
	return "";
    } else {
	my $pct = int( 0.5 + ( 100 * ( $size / $baseline_font_size ) ) );
	return "font-size: $pct%";
    }
}

=head2 fix_css_fontsize( CSS text, CSS filename, EPUB filename )

Return corrected CSS with bad font-size attributes corrected or removed,
or undef if no corrections were needed.

=cut


# if I want this to do reporting in verbose mode, I probably need to split and work with
# one block at a time, like fix_css_colors(), even though that's not necessary to fix the
# font sizes as such, since they aren't as context-sensitive.
sub fix_css_fontsize {
    my ($csstext, $css_fn, $epub_fn) = @_;
    return if not $csstext;
    my $errors = 0;
    my $printed_filename = 0;

    say "Checking $epub_fn:$css_fn for absolute font sizes" if $verbose;

    if ( $csstext =~ m/font-size: \s* ([0-9]+) (?:\.[0-9]+)? ([cm]m|p[xtc]|in)/x ) {
	$errors++;
	$csstext =~ s/(font-size: \s* [^;}]+) / &corrected_font_size( $1 ) /exg;
	# if that resulted in empty statements, remove the extra semicolons
	$csstext =~ s/;\s*;/;/g;
        return $csstext;
    } else {
	return undef;
    }
}

=head2 get_css_from_style_element( HTML text )

Search HTML file contents, supplied as a string, for <style> elements
and return the contents.

=cut

sub get_css_from_style_element {
    my $filetext = shift;
    my $csstext = '';
    while ( $filetext =~ m(<style type="text/css">([^<]+)</style>)gi ) {
	say "found match" if $verbose;
	$csstext .= $1 . "\n";
    }
    return $csstext;
}

=head2 swap_in_corrected_style_element( HTML text, CSS text )

Replace the contents of the first <style> element with the CSS text argument.

N.B. This might not produce the desired results for an epub that has multiple
style elements in each .html file.

=cut

sub swap_in_corrected_style_element {
    my ( $html_contents, $corrected_css ) = @_;
    $html_contents =~ s(<style type="text/css">[^<]+</style>)
		       (<style type="text/css">$corrected_css</style>)i;

    return $html_contents;
}

=head2 make_corrections_to_css( CSS text, CSS filename, EPUB filename )

Based on command line options about how to fix things, call the appropriate
functions to transform CSS.  Return a two-element array; the first is
the corrected CSS, or undef if there are no changes, and the second is 
the number of types of changes made (0-2).

=cut


my $no_colors = 0;  # set by GetOptions() in main, used here in make_corrections_to_css()
my $darkmode = 0;
sub make_corrections_to_css {
    my ($css_contents, $css_fn, $epub_fn ) = @_;
    return if not defined $css_contents;

    my $corrections = 0;
    my $new_css;
    my $colorfix_css;
    if ( $colorfix_css = $no_colors ? remove_css_colors( $css_contents, $css_fn, $epub_fn )
	 : ( $darkmode ? force_darkmode( $css_contents, $css_fn, $epub_fn )
	     : fix_css_colors( $css_contents, $css_fn, $epub_fn )
	 ) )
    {
	say "$epub_fn has foreground color without background color.";
	$new_css = $colorfix_css;
	$corrections++;
    }

    my $fontfix_css;
    if ( $fontfix_css = fix_css_fontsize( ($new_css ? $new_css : $css_contents) , $css_fn, $epub_fn ) ) {
	say "$epub_fn has fixed font-size.";
	$corrections++;
	$new_css = $fontfix_css;
    }
    #say "Corrected css:\n\n$new_css \n" if $new_css && $verbose;
    return ($new_css, $corrections);
}


=head2 add_title_suffix( EPUB zip object, EPUB filename )

Prepend the --title option string to the <dc:title> element in the
.opf file of the target epub.

=cut


my $title_suffix;  # set by GetOptions() in main and used by add_title_suffix()

sub add_title_suffix {
    my ( $epubzip, $epubfilename ) = ( @_ );
    my @opf = $epubzip->membersMatching( ".*\.opf" );
    if ( not @opf ) {
	say "No opf file found in $epubfilename";
    } elsif ( @opf > 1 ) {
	say "Multiple opf files found in $epubfilename";
    }
	   
    for my $opf ( @opf ) {
	my $opf_text = $epubzip->contents( $opf );
	# find title and append $title_suffix, then stuff the file back into the $epubzip
	my $matches = ($opf_text =~ s(<dc:title(?:[^>])*>([^<]*)</dc:title>)(<dc:title id="title">$1 $title_suffix</dc:title>)i);

	if ( not $matches ) {
	    say "No <dc:title> element found in ". $opf->fileName;
	}
	say "Added $title_suffix to <dc:title> in " . $opf->fileName  if $verbose;
	$epubzip->contents( $opf->fileName, $opf_text );
    }
}


=head2 save_modified_zip( EPUB zip object, EPUB filename )

Save the modified EPUB object to disk, either appending '_fixed' to the modified
file's name or saving it under the original name and appending _orig to the 
original file's name.

=cut

my $replace; # set by GetOptions() in main and used by save_modified_zip()
sub save_modified_zip {
    my ( $epubzip, $epubfilename ) = ( @_ );

    my $fixed_filename = $epubfilename;
    $fixed_filename =~ s/\.epub$/$file_suffix . ".epub"/e;
    die if $fixed_filename eq $epubfilename;
    $epubzip->writeToFileNamed( $fixed_filename );
    if ( $replace ) {
	my $orig_filename = $epubfilename;
	$orig_filename =~ s/\.epub$/_orig.epub/;
	rename $epubfilename, $orig_filename	or die "Couldn't rename $epubfilename to $orig_filename\n";
	rename $fixed_filename, $epubfilename	or die "Couldn't rename $fixed_filename to $epubfilename\n";
	say "Wrote $epubfilename with corrected css, original is at $orig_filename";
    } else {
	say "Wrote $fixed_filename with corrected css";
    }
}


=head2 main function

Process command line options, then iterate over filenames and directories
given on the command line.  If a directory is found, iterate over it and collect
the .epub filenames, appending them to @ARGV; otherwise, process the epub file by
checking for .css files, then for .html files, and dealing with the CSS text in both
appropriately.  Save the resulting file if there were any changes, then repeat for
the next file.

=cut


my $help;
my $manual;
my $recurse = 0;
my $run_test = 0;

Getopt::Long::Configure('bundling');
my $getopt_rc = GetOptions(
    'h|help'        => \$help,
    'v|verbose'     => \$verbose,
    'r|replace'     => \$replace,
    'n|nocolors'    => \$no_colors,
    'd|darkmode'    => \$darkmode,
    'F|filesuffix=s'=> \$file_suffix,
    'f|fontsize=i'  => \$baseline_font_size,
    't|title=s'     => \$title_suffix,
    'R|recurse'     => \$recurse,
    'T|test'        => \$run_test,
    'm|manual'      => \$manual,
);    


if ( $help or not $getopt_rc ) {
    help;
    exit(0);
} elsif ( $manual ) {
    pod2usage(-verbose => 2);
    exit;
}



if ( $run_test ) {
    run_test();
    exit(0);
}

while ( my $epubfilename = shift @ARGV ) {
    say "Checking $epubfilename" if $verbose;
    if ( $epubfilename !~ m/\.epub$/i ) {
	if ( -d $epubfilename ) {
	    my $func = sub {
		if ( $_[0] =~ m/\.epub$/i ) {
		    push @ARGV, $_[0];
		}
	    };
	    dirwalk_with_callback( dir => $epubfilename,
		       func => $func,
		       recurse => $recurse,
		       verbose => $verbose );
	} else {
	    say "$epubfilename is neither an epub nor a directory";
	}
	next;	    
    }
    
    my $epubzip = Archive::Zip->new();
    unless ( $epubzip->read( $epubfilename ) == AZ_OK ) {
	say "Couldn't read $epubfilename as as zip file";
	next;
    }

    my $changed_files = 0;
    my @cssfiles = $epubzip->membersMatching( ".*\.css" );
    for my $css ( @cssfiles ) {
	my $css_contents = $epubzip->contents( $css );
	my $fn = $css->fileName;
	my ( $corrected_css, $made_corrections ) = make_corrections_to_css( $css_contents, $fn, $epubfilename );
	if ( $made_corrections ) {
	    $epubzip->contents( $fn, $corrected_css );
	    $changed_files++;
	}
    }

    my @htmlfiles = $epubzip->membersMatching( ".*\.x?html?" );  # xhtml, html, htm
    for my $html ( @htmlfiles ) {
	my $html_contents = $epubzip->contents( $html );
	my $fn = $html->fileName;
	say "checking $fn for style tag" if $verbose;
	my $css_contents = get_css_from_style_element( $html_contents );
	next unless $css_contents;
	my ( $corrected_css, $made_corrections ) = make_corrections_to_css( $css_contents, $fn, $epubfilename );
	if ( $made_corrections ) {
	    my $new_html_contents = swap_in_corrected_style_element( $html_contents, $corrected_css );
	    $epubzip->contents( $fn, $new_html_contents );
	    $changed_files++;
	}
    }

    if ( $changed_files ) {
	if ( $title_suffix ) {
	    add_title_suffix( $epubzip, $epubfilename );
	}
	save_modified_zip( $epubzip, $epubfilename );
    } elsif ( $verbose ) {
	say "No changes needed to $epubfilename\n";
    }
}
