X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2FMisc.pm;h=fd2c32513547ec68fc0faa3f7f12c30a38831bc4;hp=c3e2d0c90cdf71713ea15073c7acde849a01f6cc;hb=a5bfed744069d69a1fe07eca1a64a2b22692cc22;hpb=9aefe106db1f06d4e1047deaaf4aa7bdbdeb8fd2 diff --git a/FS/FS/Misc.pm b/FS/FS/Misc.pm index c3e2d0c90..fd2c32513 100644 --- a/FS/FS/Misc.pm +++ b/FS/FS/Misc.pm @@ -1,24 +1,31 @@ package FS::Misc; use strict; -use vars qw ( @ISA @EXPORT_OK $DEBUG ); +use vars qw ( @ISA @EXPORT_OK $DEBUG $DISABLE_ALL_NOTICES ); use Exporter; use Carp; use Data::Dumper; use IPC::Run qw( run timeout ); # for _pslatex use IPC::Run3; # for do_print... should just use IPC::Run i guess +use File::Temp; +use Tie::IxHash; +use Encode; #do NOT depend on any FS:: modules here, causes weird (sometimes unreproducable #until on client machine) dependancy loops. put them in FS::Misc::Something #instead @ISA = qw( Exporter ); -@EXPORT_OK = qw( send_email send_fax - states_hash counties state_label +@EXPORT_OK = qw( send_email generate_email send_fax + states_hash counties cities state_label card_types + pkg_freqs generate_ps generate_pdf do_print + csv_from_fixed + ocr_image + money_pretty ); -$DEBUG = 1; +$DEBUG = 0; =head1 NAME @@ -34,7 +41,33 @@ FS::Misc - Miscellaneous subroutines Miscellaneous subroutines. This module contains miscellaneous subroutines called from multiple other modules. These are not OO or necessarily related, -but are collected here to elimiate code duplication. +but are collected here to eliminate code duplication. + +=head1 DISABLE ALL NOTICES + +Set $FS::Misc::DISABLE_ALL_NOTICES to suppress: + +=over 4 + +=item FS::cust_bill::send_csv + +=item FS::cust_bill::spool_csv + +=item FS::msg_template::email::send_prepared + +=item FS::Misc::send_email + +=item FS::Misc::do_print + +=item FS::Misc::send_fax + +=item FS::Template_Mixin::postal_mail_fsinc + +=back + +=cut + +$DISABLE_ALL_NOTICES = 0; =head1 SUBROUTINES @@ -44,33 +77,65 @@ but are collected here to elimiate code duplication. Options: -I - (required) +=over 4 + +=item from + +(required) + +=item to + +(required) comma-separated scalar or arrayref of recipients + +=item subject + +(required) + +=item content-type -I - (required) comma-separated scalar or arrayref of recipients +(optional) MIME type for the body -I - (required) +=item body -I - (optional) MIME type for the body +(required unless I is true) arrayref of body text lines -I - (required unless I is true) arrayref of body text lines +=item mimeparts -I - (optional, but required if I is true) arrayref of MIME::Entity->build PARAMHASH refs or MIME::Entity objects. These will be passed as arguments to MIME::Entity->attach(). +(optional, but required if I is true) arrayref of MIME::Entity->build PARAMHASH refs or MIME::Entity objects. These will be passed as arguments to MIME::Entity->attach(). -I - (optional) when set true, send_email will ignore the I option and simply construct a message with the given I. In this case, +=item nobody + +(optional) when set true, send_email will ignore the I option and simply construct a message with the given I. In this case, I, if specified, overrides the default "multipart/mixed" for the outermost MIME container. -I - (optional) when using nobody, optional top-level MIME +=item content-encoding + +(optional) when using nobody, optional top-level MIME encoding which, if specified, overrides the default "7bit". -I - (optional) type parameter for multipart/related messages +=item type + +(optional) type parameter for multipart/related messages + +=item custnum + +(optional) L key; if passed, the message will be logged +(if logging is enabled) with this custnum. + +=item msgnum + +(optional) L key, for logging. + +=back =cut use vars qw( $conf ); use Date::Format; -use Mail::Header; -use Mail::Internet 2.00; use MIME::Entity; +use Email::Sender::Simple qw(sendmail); +use Email::Sender::Transport::SMTP; +use Email::Sender::Transport::SMTP::TLS 0.11; use FS::UID; FS::UID->install_callback( sub { @@ -79,6 +144,12 @@ FS::UID->install_callback( sub { sub send_email { my(%options) = @_; + + if ( $DISABLE_ALL_NOTICES ) { + warn 'send_email() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG; + return; + } + if ( $DEBUG ) { my %doptions = %options; $doptions{'body'} = '(full body not shown in debug)'; @@ -86,8 +157,7 @@ sub send_email { # join("\n", map { " $_: ". $options{$_} } keys %options ). "\n" } - $ENV{MAILADDRESS} = $options{'from'}; - my $to = ref($options{to}) ? join(', ', @{ $options{to} } ) : $options{to}; + my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} ); my @mimeargs = (); my @mimeparts = (); @@ -117,7 +187,11 @@ sub send_email { unshift @mimeparts, { 'Type' => ( $options{'content-type'} || 'text/plain' ), - 'Data' => $options{'body'}, + 'Charset' => 'UTF-8', + 'Data' => ( $options{'content-type'} =~ /^text\// + ? Encode::encode_utf8( $options{'body'} ) + : $options{'body'} + ), 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ), 'Disposition' => 'inline', }; @@ -126,31 +200,43 @@ sub send_email { @mimeargs = ( 'Type' => ( $options{'content-type'} || 'text/plain' ), - 'Data' => $options{'body'}, + 'Data' => ( $options{'content-type'} =~ /^text\// + ? Encode::encode_utf8( $options{'body'} ) + : $options{'body'} + ), 'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ), + 'Charset' => 'UTF-8', ); } } + my $from = $options{from}; + $from =~ s/^\s*//; $from =~ s/\s*$//; + if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) { + # a common idiom + $from = $2; + } + my $domain; - if ( $options{'from'} =~ /\@([\w\.\-]+)/ ) { + if ( $from =~ /\@([\w\.\-]+)/ ) { $domain = $1; } else { warn 'no domain found in invoice from address '. $options{'from'}. - '; constructing Message-ID @example.com'; + '; constructing Message-ID (and saying HELO) @example.com'; $domain = 'example.com'; } my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain"; + my $time = time; my $message = MIME::Entity->build( 'From' => $options{'from'}, - 'To' => $to, + 'To' => join(', ', @to), 'Sender' => $options{'from'}, 'Reply-To' => $options{'from'}, - 'Date' => time2str("%a, %d %b %Y %X %z", time), - 'Subject' => $options{'subject'}, + 'Date' => time2str("%a, %d %b %Y %X %z", $time), + 'Subject' => Encode::encode('MIME-Header', $options{'subject'}), 'Message-ID' => "<$message_id>", @mimeargs, ); @@ -185,101 +271,192 @@ sub send_email { } - my $smtpmachine = $conf->config('smtpmachine'); - $!=0; + #send the email - $message->mysmtpsend( 'Host' => $smtpmachine, - 'MailFrom' => $options{'from'}, - ); + my %smtp_opt = ( 'host' => $conf->config('smtpmachine'), + 'helo' => $domain, + ); -} + my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') ); + $smtp_opt{'port'} = $port; -#this kludges a "mysmtpsend" method into Mail::Internet for send_email above -#now updated for MailTools v2! -package Mail::Internet; - -use Mail::Address; -use Net::SMTP; -use Net::Domain; - -sub Mail::Internet::mysmtpsend($@) { - my ($self, %opt) = @_; - - my $host = $opt{Host}; - my $envelope = $opt{MailFrom}; # || mailaddress(); - my $quit = 1; - - my ($smtp, @hello); - - push @hello, Hello => $opt{Hello} - if defined $opt{Hello}; - - push @hello, Port => $opt{Port} - if exists $opt{Port}; - - push @hello, Debug => $opt{Debug} - if exists $opt{Debug}; - -# if(!defined $host) -# { local $SIG{__DIE__}; -# my @hosts = qw(mailhost localhost); -# unshift @hosts, split /\:/, $ENV{SMTPHOSTS} -# if defined $ENV{SMTPHOSTS}; -# -# foreach $host (@hosts) -# { $smtp = eval { Net::SMTP->new($host, @hello) }; -# last if defined $smtp; -# } -# } -# elsif(ref($host) && UNIVERSAL::isa($host,'Net::SMTP')) - if(ref($host) && UNIVERSAL::isa($host,'Net::SMTP')) - { $smtp = $host; - $quit = 0; - } - else - { #local $SIG{__DIE__}; - #$smtp = eval { Net::SMTP->new($host, @hello) }; - $smtp = Net::SMTP->new($host, @hello); + my $transport; + if ( defined($enc) && $enc eq 'starttls' ) { + $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password); + $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt ); + } else { + if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) { + $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password); } + $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls'; + $transport = Email::Sender::Transport::SMTP->new( %smtp_opt ); + } + + push @to, $options{bcc} if defined($options{bcc}); + # fully unpack all addresses found in @to (including Bcc) to make the + # envelope list + my @env_to; + foreach my $dest (@to) { + push @env_to, map { $_->address } Email::Address->parse($dest); + } - unless ( defined($smtp) ) { - my $err = $!; - $err =~ s/Invalid argument/Unknown host/; - return "can't connect to $host: $err" - } + local $@; # just in case + eval { sendmail($message, { transport => $transport, + from => $from, + to => \@env_to }) }; + + my $error = ''; + if(ref($@) and $@->isa('Email::Sender::Failure')) { + $error = $@->code.' ' if $@->code; + $error .= $@->message; + } + else { + $error = $@; + } - my $head = $self->cleaned_header_dup; + # Logging + if ( $conf->exists('log_sent_mail') ) { + my $cust_msg = FS::cust_msg->new({ + 'env_from' => $options{'from'}, + 'env_to' => join(', ', @env_to), + 'header' => $message->header_as_string, + 'body' => $message->body_as_string, + '_date' => $time, + 'error' => $error, + 'custnum' => $options{'custnum'}, + 'msgnum' => $options{'msgnum'}, + 'status' => ($error ? 'failed' : 'sent'), + 'msgtype' => $options{'msgtype'}, + }); + my $log_error = $cust_msg->insert; + warn "Error logging message: $log_error\n" if $log_error; # at least warn + } + $error; + +} - $head->delete('Bcc'); +=item generate_email OPTION => VALUE ... - # Who is it to +Options: - my @rcpt = map { ref $_ ? @$_ : $_ } grep { defined } @opt{'To','Cc','Bcc'}; - @rcpt = map { $head->get($_) } qw(To Cc Bcc) - unless @rcpt; +=over 4 - my @addr = map {$_->address} Mail::Address->parse(@rcpt); - #@addr or return (); - return 'No valid destination addresses found!' - unless(@addr); +=item from - # Send it +Sender address, required - my $ok = $smtp->mail($envelope) - && $smtp->to(@addr) - && $smtp->data(join("", @{$head->header}, "\n", @{$self->body})); +=item to + +Recipient address, required + +=item bcc + +Blind copy address, optional + +=item subject + +email subject, required + +=item html_body + +Email body (HTML alternative). Arrayref of lines, or scalar. + +Will be placed inside an HTML tag. + +=item text_body + +Email body (Text alternative). Arrayref of lines, or scalar. + +=item custnum, msgnum (optional) + +Customer and template numbers, passed through to send_email for logging. + +=back + +Constructs a multipart message from text_body and html_body. + +=cut + +#false laziness w/FS::cust_bill::generate_email + +use MIME::Entity; +use HTML::Entities; + +sub generate_email { + my %args = @_; + + my $me = '[FS::Misc::generate_email]'; + + my @fields = qw(from to bcc subject custnum msgnum msgtype); + my %return; + @return{@fields} = @args{@fields}; + + warn "$me creating HTML/text multipart message" + if $DEBUG; + + $return{'nobody'} = 1; + + my $alternative = build MIME::Entity + 'Type' => 'multipart/alternative', + 'Encoding' => '7bit', + 'Disposition' => 'inline' + ; + + my $data; + if ( ref($args{'text_body'}) eq 'ARRAY' ) { + $data = join("\n", @{ $args{'text_body'} }); + } else { + $data = $args{'text_body'}; + } + + $alternative->attach( + 'Type' => 'text/plain', + 'Encoding' => 'quoted-printable', + 'Charset' => 'UTF-8', + #'Encoding' => '7bit', + 'Data' => Encode::encode_utf8($data), + 'Disposition' => 'inline', + ); + + my @html_data; + if ( ref($args{'html_body'}) eq 'ARRAY' ) { + @html_data = @{ $args{'html_body'} }; + } else { + @html_data = split(/\n/, $args{'html_body'}); + } + + $alternative->attach( + 'Type' => 'text/html', + 'Encoding' => 'quoted-printable', + 'Data' => [ '', + ' ', + ' ', + ' '. encode_entities($return{'subject'}), + ' ', + ' ', + ' ', + ( map Encode::encode_utf8($_), @html_data ), + ' ', + '', + ], + 'Disposition' => 'inline', + #'Filename' => 'invoice.pdf', + ); + + #no other attachment: + # multipart/related + # multipart/alternative + # text/plain + # text/html + + $return{'content-type'} = 'multipart/related'; + $return{'mimeparts'} = [ $alternative ]; + $return{'type'} = 'multipart/alternative'; #Content-Type of first part... + #$return{'disposition'} = 'inline'; + + %return; - #$quit && $smtp->quit; - #$ok ? @addr : (); - if ( $ok ) { - $quit && $smtp->quit; - return ''; - } else { - return $smtp->code. ' '. $smtp->message; - } } -package FS::Misc; -#eokludge =item send_fax OPTION => VALUE ... @@ -305,6 +482,11 @@ sub send_fax { die 'HylaFAX support has not been configured.' unless $conf->exists('hylafax'); + if ( $DISABLE_ALL_NOTICES ) { + warn 'send_fax() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG; + return; + } + eval { require Fax::Hylafax::Client; }; @@ -377,6 +559,9 @@ use Locale::SubCountry; sub states_hash { my($country) = @_; + #a hash? not expecting an explosion of business from unrecognized countries.. + return states_hash_nosubcountry($country) if $country eq 'XC'; + my @states = # sort map { s/[\n\r]//g; $_; } @@ -390,7 +575,7 @@ sub states_hash { #it could throw a fatal "Invalid country code" error (for example "AX") my $subcountry = eval { new Locale::SubCountry($country) } - or return ( '', '(n/a)' ); + or return (); # ( '', '(n/a)' ); #"i see your schwartz is as big as mine!" map { ( $_->[0] => $_->[1] ) } @@ -399,6 +584,27 @@ sub states_hash { @states; } +sub states_hash_nosubcountry { + my($country) = @_; + + my @states = +# sort + map { s/[\n\r]//g; $_; } + map { $_->state; } + qsearch({ + 'select' => 'state', + 'table' => 'cust_main_county', + 'hashref' => { 'country' => $country }, + 'extra_sql' => 'GROUP BY state', + }); + + #"i see your schwartz is as big as mine!" + map { ( $_->[0] => $_->[1] ) } + sort { $a->[1] cmp $b->[1] } + map { [ $_ => $_ ] } + @states; +} + =item counties STATE COUNTRY Returns a list of counties for this state and country. @@ -408,6 +614,7 @@ Returns a list of counties for this state and country. sub counties { my( $state, $country ) = @_; + map { $_ } #return num_counties($state, $country) unless wantarray; sort map { s/[\n\r]//g; $_; } map { $_->county } qsearch({ @@ -419,6 +626,28 @@ sub counties { }); } +=item cities COUNTY STATE COUNTRY + +Returns a list of cities for this county, state and country. + +=cut + +sub cities { + my( $county, $state, $country ) = @_; + + map { $_ } #return num_cities($county, $state, $country) unless wantarray; + sort map { s/[\n\r]//g; $_; } + map { $_->city } + qsearch({ + 'select' => 'DISTINCT city', + 'table' => 'cust_main_county', + 'hashref' => { 'county' => $county, + 'state' => $state, + 'country' => $country, + }, + }); +} + =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT =cut @@ -487,6 +716,39 @@ sub card_types { \%card_types; } +=item pkg_freqs + +Returns a hash reference of allowed package billing frequencies. + +=cut + +sub pkg_freqs { + tie my %freq, 'Tie::IxHash', ( + '0' => '(no recurring fee)', + '1h' => 'hourly', + '1d' => 'daily', + '2d' => 'every two days', + '3d' => 'every three days', + '1w' => 'weekly', + '2w' => 'biweekly (every 2 weeks)', + '1' => 'monthly', + '45d' => 'every 45 days', + '2' => 'bimonthly (every 2 months)', + '3' => 'quarterly (every 3 months)', + '4' => 'every 4 months', + '137d' => 'every 4 1/2 months (137 days)', + '6' => 'semiannually (every 6 months)', + '12' => 'annually', + '13' => 'every 13 months (annually +1 month)', + '24' => 'biannually (every 2 years)', + '36' => 'triannually (every 3 years)', + '48' => '(every 4 years)', + '60' => '(every 5 years)', + '120' => '(every 10 years)', + ) ; + \%freq; +} + =item generate_ps FILENAME Returns an postscript rendition of the LaTex file, as a scalar. @@ -504,13 +766,18 @@ sub generate_ps { _pslatex($file); - system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0 + my $papersize = $conf->config('papersize') || 'letter'; + + local($SIG{CHLD}) = sub {}; + + system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0 or die "dvips failed"; open(POSTSCRIPT, "<$file.ps") or die "can't open $file.ps: $! (error in LaTeX template?)\n"; - unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex"); + unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex") + unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting'); my $ps = ''; @@ -558,8 +825,12 @@ sub generate_pdf { my $sfile = shell_quote $file; #system('dvipdf', "$file.dvi", "$file.pdf" ); + my $papersize = $conf->config('papersize') || 'letter'; + + local($SIG{CHLD}) = sub {}; + system( - "dvips -q -t letter -f $sfile.dvi ". + "dvips -q -f $sfile.dvi -t $papersize ". "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ". " -c save pop -" ) == 0 @@ -568,7 +839,8 @@ sub generate_pdf { open(PDF, "<$file.pdf") or die "can't open $file.pdf: $! (error in LaTeX template?)\n"; - unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex"); + unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex") + unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting'); my $pdf = ''; while () { @@ -588,7 +860,7 @@ sub _pslatex { my @cmd = ( 'latex', - #'-interaction=errorstopmode', + '-interaction=batchmode', '\AtBeginDocument{\RequirePackage{pslatex}}', '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}', '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}', @@ -601,26 +873,50 @@ sub _pslatex { for ( 1, 2 ) { local($SIG{CHLD}) = sub {}; - #run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) ) - run( \@cmd, timeout($timeout) ) - or die "pslatex $file.tex failed; see $file.log for details?\n"; + run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) ) + or warn "bad exit status from pslatex pass $_\n"; } + return if -e "$file.dvi" && -s "$file.dvi"; + die "pslatex $file.tex failed, see $file.log for details?\n"; + } -=item print ARRAYREF +=item do_print ARRAYREF [, OPTION => VALUE ... ] Sends the lines in ARRAYREF to the printer. +Options available are: + +=over 4 + +=item agentnum + +Uses this agent's 'lpr' configuration setting override instead of the global +value. + +=item lpr + +Uses this command instead of the configured lpr command (overrides both the +global value and agentnum). + =cut sub do_print { - my $data = shift; + my( $data, %opt ) = @_; - my $lpr = $conf->config('lpr'); + if ( $DISABLE_ALL_NOTICES ) { + warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG; + return; + } + + my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} ) + ? $opt{'lpr'} + : $conf->config('lpr', $opt{'agentnum'} ); my $outerr = ''; + local($SIG{CHLD}) = sub {}; run3 $lpr, $data, \$outerr, \$outerr; if ( $? ) { $outerr = ": $outerr" if length($outerr); @@ -629,6 +925,141 @@ sub do_print { } +=item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ] + +Converts the filehandle referenced by FILEREF from fixed length record +lines to a CSV file according to the lengths specified in LENGTH_LISTREF. +The CALLBACKS_LISTREF refers to a correpsonding list of coderefs. Each +should return the value to be substituted in place of its single argument. + +Returns false on success or an error if one occurs. + +=cut + +sub csv_from_fixed { + my( $fhref, $countref, $lengths, $callbacks) = @_; + + eval { require Text::CSV_XS; }; + return $@ if $@; + + my $ofh = $$fhref; + my $unpacker = new Text::CSV_XS; + my $total = 0; + my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths; + + my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc"; + my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX", + DIR => $dir, + UNLINK => 0, + ) or return "can't open temp file: $!\n" + if $template; + + while ( defined(my $line=<$ofh>) ) { + $$countref++; + if ( $template ) { + my $column = 0; + + chomp $line; + return "unexpected input at line $$countref: $line". + " -- expected $total but received ". length($line) + unless length($line) == $total; + + $unpacker->combine( map { my $i = $column++; + defined( $callbacks->[$i] ) + ? &{ $callbacks->[$i] }( $_ ) + : $_ + } unpack( $template, $line ) + ) + or return "invalid data for CSV: ". $unpacker->error_input; + + print $fh $unpacker->string(), "\n" + or return "can't write temp file: $!\n"; + } + } + + if ( $template ) { close $$fhref; $$fhref = $fh } + + seek $$fhref, 0, 0; + ''; +} + +=item ocr_image IMAGE_SCALAR + +Runs OCR on the provided image data and returns a list of text lines. + +=cut + +sub ocr_image { + my $logo_data = shift; + + #XXX use conf dir location from Makefile + my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc; + my $fh = new File::Temp( + TEMPLATE => 'bizcard.XXXXXXXX', + SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too + DIR => $dir, + UNLINK => 0, + ) or die "can't open temp file: $!\n"; + + my $filename = $fh->filename; + + print $fh $logo_data; + close $fh; + + local($SIG{CHLD}) = sub {}; + + run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" ) + or die "ocroscript recognize failed\n"; + + run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT ) + or die "ocroscript hocr-to-text failed\n"; + + my @lines = split(/\n/, ); + + foreach (@lines) { s/\.c0m\s*$/.com/; } + + @lines; +} + +=item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ] + +DEPRECATED + Use Unicode::Truncate truncate_egc instead + +A replacement for "substr" that counts raw bytes rather than logical +characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters +rather than output them. Unlike real "substr", is not an lvalue. + +=cut + +# sub bytes_substr { +# my ($string, $offset, $length, $repl) = @_; +# my $bytes = substr( +# Encode::encode('utf8', $string), +# $offset, +# $length, +# Encode::encode('utf8', $repl) +# ); +# my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET; +# return Encode::decode('utf8', $bytes, $chk); +# } + +=item money_pretty + +Accepts a postive or negative numerical value. +Returns amount formatted for display, +including money character. + +=cut + +sub money_pretty { + my $amount = shift; + my $money_char = $conf->{'money_char'} || '$'; + $amount = sprintf("%0.2f",$amount); + $amount =~ s/^(-?)/$1$money_char/; + return $amount; +} + =back =head1 BUGS