X-Git-Url: http://git.freeside.biz/gitweb/?p=freeside.git;a=blobdiff_plain;f=FS%2FFS%2FMisc.pm;h=1c100eacd0418537515ae00b4dca284dfdbaa0b2;hp=54467a1fb75eea2dca3068fa549f343819fd003d;hb=3adb46fccf9f631e188ea5383bd147b340477639;hpb=c648976f0b7975f2328ebd7ba8c711fad0ca4195 diff --git a/FS/FS/Misc.pm b/FS/FS/Misc.pm index 54467a1fb..1c100eacd 100644 --- a/FS/FS/Misc.pm +++ b/FS/FS/Misc.pm @@ -1,19 +1,29 @@ 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 + email_sender_transport_or_error + states_hash counties cities state_label card_types - generate_ps do_print + pkg_freqs + generate_ps generate_pdf do_print + csv_from_fixed + ocr_image + money_pretty ); $DEBUG = 0; @@ -32,7 +42,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 @@ -42,33 +78,64 @@ 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) -I - (required) comma-separated scalar or arrayref of recipients +=item content-type -I - (required) +(optional) MIME type for the body -I - (optional) MIME type for the body +=item body -I - (required unless I is true) arrayref of body text lines +(required unless I is true) arrayref of body text lines -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(). +=item mimeparts -I - (optional) when set true, send_email will ignore the I option and simply construct a message with the given I. In this case, +(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(). + +=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 FS::UID; FS::UID->install_callback( sub { @@ -77,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)'; @@ -84,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 = (); @@ -115,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', }; @@ -124,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, ); @@ -183,101 +271,208 @@ sub send_email { } - my $smtpmachine = $conf->config('smtpmachine'); - $!=0; + #send the email - $message->mysmtpsend( 'Host' => $smtpmachine, - 'MailFrom' => $options{'from'}, - ); + 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); + } -} + my $transport = email_sender_transport_or_error($domain); -#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 $error = ''; + if ( ref($transport) ) { - unless ( defined($smtp) ) { - my $err = $!; - $err =~ s/Invalid argument/Unknown host/; - return "can't connect to $host: $err" + local $SIG{__DIE__}; # don't want Mason __DIE__ handler active + local $@; # just in case + eval { sendmail($message, { transport => $transport, + from => $from, + to => \@env_to }) }; + + if (ref($@) and $@->isa('Email::Sender::Failure')) { + $error = $@->code.' ' if $@->code; + $error .= $@->message; + } else { + $error = $@; } - my $head = $self->cleaned_header_dup; + } else { + $error = $transport; + } - $head->delete('Bcc'); + # 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 + } - # Who is it to + $error; + +} - my @rcpt = map { ref $_ ? @$_ : $_ } grep { defined } @opt{'To','Cc','Bcc'}; - @rcpt = map { $head->get($_) } qw(To Cc Bcc) - unless @rcpt; +sub email_sender_transport_or_error { + my $domain = shift; - my @addr = map {$_->address} Mail::Address->parse(@rcpt); - #@addr or return (); - return 'No valid destination addresses found!' - unless(@addr); + my %smtp_opt = ( 'host' => $conf->config('smtpmachine'), + 'helo' => $domain, + ); - # Send it + my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') ); + $smtp_opt{'port'} = $port; - my $ok = $smtp->mail($envelope) - && $smtp->to(@addr) - && $smtp->data(join("", @{$head->header}, "\n", @{$self->body})); + if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) { + $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password); + } elsif ( defined($enc) && $enc eq 'starttls') { + return "SMTP settings misconfiguration: STARTTLS enabled in ". + "smtp-encryption but smtp-username or smtp-password missing"; + } + + if ( defined($enc) ) { + $smtp_opt{'ssl'} = 'starttls' if $enc eq 'starttls'; + $smtp_opt{'ssl'} = 1 if $enc eq 'tls'; + } + + Email::Sender::Transport::SMTP->new( %smtp_opt ); + +} + +=item generate_email OPTION => VALUE ... + +Options: + +=over 4 + +=item from + +Sender address, required + +=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 ... @@ -303,6 +498,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; }; @@ -375,6 +575,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; $_; } @@ -388,7 +591,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] ) } @@ -397,6 +600,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. @@ -406,6 +630,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({ @@ -417,6 +642,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 @@ -485,6 +732,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. @@ -500,20 +780,20 @@ sub generate_ps { my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc; chdir($dir); - my $sfile = shell_quote $file; + _pslatex($file); + + my $papersize = $conf->config('papersize') || 'letter'; - system("pslatex $sfile.tex >/dev/null 2>&1") == 0 - or die "pslatex $file.tex failed; see $file.log for details?\n"; - system("pslatex $sfile.tex >/dev/null 2>&1") == 0 - or die "pslatex $file.tex failed; see $file.log for details?\n"; + local($SIG{CHLD}) = sub {}; - system('dvips', '-q', '-t', 'letter', "$file.dvi", '-o', "$file.ps" ) == 0 + 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 = ''; @@ -537,20 +817,122 @@ sub generate_ps { } -=item print ARRAYREF +=item generate_pdf FILENAME -Sends the lines in ARRAYREF to the printer. +Returns an PDF rendition of the LaTex file, as a scalar. FILENAME does not +contain the .tex suffix and is unlinked by this function. =cut -use IPC::Run3; +use String::ShellQuote; + +sub generate_pdf { + my $file = shift; + + my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc; + chdir($dir); + + #system('pdflatex', "$file.tex"); + #system('pdflatex', "$file.tex"); + #! LaTeX Error: Unknown graphics extension: .eps. + + _pslatex($file); + + my $sfile = shell_quote $file; + + #system('dvipdf', "$file.dvi", "$file.pdf" ); + my $papersize = $conf->config('papersize') || 'letter'; + + local($SIG{CHLD}) = sub {}; + + system( + "dvips -q -f $sfile.dvi -t $papersize ". + "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ". + " -c save pop -" + ) == 0 + or die "dvips | gs failed: $!"; + + 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") + unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting'); + + my $pdf = ''; + while () { + $pdf .= $_; + } + + close PDF; + + return $pdf; + +} + +sub _pslatex { + my $file = shift; + + #my $sfile = shell_quote $file; + + my @cmd = ( + 'latex', + '-interaction=batchmode', + '\AtBeginDocument{\RequirePackage{pslatex}}', + '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}', + '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}', + '\PSLATEXTMP', + "$file.tex" + ); + + my $timeout = 30; #? should be more than enough + + for ( 1, 2 ) { + + local($SIG{CHLD}) = sub {}; + 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 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 ) = @_; + + if ( $DISABLE_ALL_NOTICES ) { + warn 'do_print() disabled by $FS::Misc::DISABLE_ALL_NOTICES' if $DEBUG; + return; + } - my $lpr = $conf->config('lpr'); + 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); @@ -559,6 +941,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