import torrus 1.0.9
[freeside.git] / FS / FS / Misc.pm
index 97ff8ed..fe8ac60 100644 (file)
@@ -5,9 +5,23 @@ use vars qw ( @ISA @EXPORT_OK $DEBUG );
 use Exporter;
 use Carp;
 use Data::Dumper;
 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;
+#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 );
 
 @ISA = qw( Exporter );
-@EXPORT_OK = qw( send_email send_fax states_hash state_label card_types );
+@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
+               );
 
 $DEBUG = 0;
 
 
 $DEBUG = 0;
 
@@ -25,7 +39,7 @@ FS::Misc - Miscellaneous subroutines
 
 Miscellaneous subroutines.  This module contains miscellaneous subroutines
 called from multiple other modules.  These are not OO or necessarily related,
 
 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 SUBROUTINES
 
 
 =head1 SUBROUTINES
 
@@ -35,33 +49,56 @@ but are collected here to elimiate code duplication.
 
 Options:
 
 
 Options:
 
-I<from> - (required)
+=over 4
+
+=item from
+
+(required)
+
+=item to
+
+(required) comma-separated scalar or arrayref of recipients
+
+=item subject
 
 
-I<to> - (required) comma-separated scalar or arrayref of recipients
+(required)
 
 
-I<subject> - (required)
+=item content-type
 
 
-I<content-type> - (optional) MIME type for the body
+(optional) MIME type for the body
 
 
-I<body> - (required unless I<nobody> is true) arrayref of body text lines
+=item body
 
 
-I<mimeparts> - (optional, but required if I<nobody> is true) arrayref of MIME::Entity->build PARAMHASH refs or MIME::Entity objects.  These will be passed as arguments to MIME::Entity->attach().
+(required unless I<nobody> is true) arrayref of body text lines
 
 
-I<nobody> - (optional) when set true, send_email will ignore the I<body> option and simply construct a message with the given I<mimeparts>.  In this case,
+=item mimeparts
+
+(optional, but required if I<nobody> 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<body> option and simply construct a message with the given I<mimeparts>.  In this case,
 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
 
 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
 
-I<content-encoding> - (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".
 
 encoding which, if specified, overrides the default "7bit".
 
-I<type> - (optional) type parameter for multipart/related messages
+=item type
+
+(optional) type parameter for multipart/related messages
+
+=back
 
 =cut
 
 use vars qw( $conf );
 use Date::Format;
 
 =cut
 
 use vars qw( $conf );
 use Date::Format;
-use Mail::Header;
-use Mail::Internet 1.44;
 use MIME::Entity;
 use MIME::Entity;
+use Email::Sender::Simple qw(sendmail);
+use Email::Sender::Transport::SMTP;
+use Email::Sender::Transport::SMTP::TLS;
 use FS::UID;
 
 FS::UID->install_callback( sub {
 use FS::UID;
 
 FS::UID->install_callback( sub {
@@ -77,8 +114,7 @@ sub send_email {
 #         join("\n", map { "  $_: ". $options{$_} } keys %options ). "\n"
   }
 
 #         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 = ();
 
   my @mimeargs = ();
   my @mimeparts = ();
@@ -130,14 +166,14 @@ sub send_email {
     $domain = $1;
   } else {
     warn 'no domain found in invoice from address '. $options{'from'}.
     $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 $message = MIME::Entity->build(
     'From'       => $options{'from'},
     $domain = 'example.com';
   }
   my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
 
   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),
     'Sender'     => $options{'from'},
     'Reply-To'   => $options{'from'},
     'Date'       => time2str("%a, %d %b %Y %X %z", time),
@@ -176,88 +212,183 @@ sub send_email {
 
   }
 
 
   }
 
-  my $smtpmachine = $conf->config('smtpmachine');
-  $!=0;
+  #send the email
+
+  my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
+                   'helo' => $domain,
+                 );
 
 
-  $message->mysmtpsend( 'Host'     => $smtpmachine,
-                        'MailFrom' => $options{'from'},
-                      );
+  my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
+  $smtp_opt{'port'} = $port;
 
 
+  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});
+  local $@; # just in case
+  eval { sendmail($message, { transport => $transport,
+                              from      => $options{from},
+                              to        => \@to }) };
+  if(ref($@) and $@->isa('Email::Sender::Failure')) {
+    return ($@->code ? $@->code.' ' : '').$@->message
+  }
+  else {
+    return $@;
+  }
 }
 
 }
 
-#this kludges a "mysmtpsend" method into Mail::Internet for send_email above
-package Mail::Internet;
+=item generate_email OPTION => VALUE ...
 
 
-use Mail::Address;
-use Net::SMTP;
+Options:
 
 
-sub Mail::Internet::mysmtpsend {
-    my $src  = shift;
-    my %opt = @_;
-    my $host = $opt{Host};
-    my $envelope = $opt{MailFrom};
-    my $noquit = 0;
-    my $smtp;
-    my @hello = defined $opt{Hello} ? (Hello => $opt{Hello}) : ();
+=over 4
 
 
-    push(@hello, 'Port', $opt{'Port'})
-       if exists $opt{'Port'};
+=item from
 
 
-    push(@hello, 'Debug', $opt{'Debug'})
-       if exists $opt{'Debug'};
+Sender address, required
 
 
-    if(ref($host) && UNIVERSAL::isa($host,'Net::SMTP')) {
-       $smtp = $host;
-       $noquit = 1;
-    }
-    else {
-       #local $SIG{__DIE__};
-       #$smtp = eval { Net::SMTP->new($host, @hello) };
-       $smtp = new Net::SMTP $host, @hello;
-    }
+=item to
 
 
-    unless ( defined($smtp) ) {
-      my $err = $!;
-      $err =~ s/Invalid argument/Unknown host/;
-      return "can't connect to $host: $err"
-    }
+Recipient address, required
 
 
-    my $hdr = $src->head->dup;
+=item bcc
 
 
-    _prephdr($hdr);
+Blind copy address, optional
 
 
-    # Who is it to
+=item subject
 
 
-    my @rcpt = map { ref($_) ? @$_ : $_ } grep { defined } @opt{'To','Cc','Bcc'};
-    @rcpt = map { $hdr->get($_) } qw(To Cc Bcc)
-       unless @rcpt;
-    my @addr = map($_->address, Mail::Address->parse(@rcpt));
+email subject, required
 
 
-    return 'No valid destination addresses found!'
-       unless(@addr);
+=item html_body
 
 
-    $hdr->delete('Bcc'); # Remove blind Cc's
+Email body (HTML alternative).  Arrayref of lines, or scalar.
 
 
-    # Send it
+Will be placed inside an HTML <BODY> tag.
 
 
-    #warn "Headers: \n" . join('',@{$hdr->header});
-    #warn "Body: \n" . join('',@{$src->body});
+=item text_body
 
 
-    my $ok = $smtp->mail( $envelope ) &&
-               $smtp->to(@addr) &&
-               $smtp->data(join("", @{$hdr->header},"\n",@{$src->body}));
+Email body (Text alternative).  Arrayref of lines, or scalar.
 
 
-    if ( $ok ) {
-      $smtp->quit
-          unless $noquit;
-      return '';
-    } else {
-      return $smtp->code. ' '. $smtp->message;
-    }
+=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 %return = (
+    'from'    => $args{'from'},
+    'to'      => $args{'to'},
+    'bcc'     => $args{'bcc'},
+    'subject' => $args{'subject'},
+  );
+
+  #if (ref($args{'to'}) eq 'ARRAY') {
+  #  $return{'to'} = $args{'to'};
+  #} else {
+  #  $return{'to'} = [ grep { $_ !~ /^(POST|FAX)$/ }
+  #                         $self->cust_main->invoicing_list
+  #                  ];
+  #}
+
+  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 = $args{'text_body'};
+  } else {
+    $data = [ split(/\n/, $args{'text_body'}) ];
+  }
+
+  $alternative->attach(
+    'Type'        => 'text/plain',
+    #'Encoding'    => 'quoted-printable',
+    'Encoding'    => '7bit',
+    'Data'        => $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'        => [ '<html>',
+                       '  <head>',
+                       '    <title>',
+                       '      '. encode_entities($return{'subject'}), 
+                       '    </title>',
+                       '  </head>',
+                       '  <body bgcolor="#e8e8e8">',
+                       @html_data,
+                       '  </body>',
+                       '</html>',
+                     ],
+    '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;
 
 }
 
 }
-package FS::Misc;
-#eokludge
+
+=item process_send_email OPTION => VALUE ...
+
+Takes arguments as per generate_email() and sends the message.  This 
+will die on any error and can be used in the job queue.
+
+=cut
+
+sub process_send_email {
+  my %message = @_;
+  my $error = send_email(generate_email(%message));
+  die "$error\n" if $error;
+  '';
+}
 
 =item send_fax OPTION => VALUE ...
 
 
 =item send_fax OPTION => VALUE ...
 
@@ -301,7 +432,7 @@ sub send_fax {
     unless exists($options{'dialstring'});
 
   if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
     unless exists($options{'dialstring'});
 
   if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
-      my $dir = $FS::UID::conf_dir. "cache.". $FS::UID::datasrc;
+      my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
       my $fh = new File::Temp(
         TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
         DIR      => $dir,
       my $fh = new File::Temp(
         TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
         DIR      => $dir,
@@ -359,13 +490,12 @@ sub states_hash {
 #     sort
      map { s/[\n\r]//g; $_; }
      map { $_->state; }
 #     sort
      map { s/[\n\r]//g; $_; }
      map { $_->state; }
-     qsearch({ 
-               'select'    => 'state',
-               'table'     => 'cust_main_county',
-               'hashref'   => { 'country' => $country },
-               'extra_sql' => 'GROUP BY state',
-            })
-  ;
+         qsearch({ 
+                   'select'    => 'state',
+                   'table'     => 'cust_main_county',
+                   'hashref'   => { 'country' => $country },
+                   'extra_sql' => 'GROUP BY state',
+                });
 
   #it could throw a fatal "Invalid country code" error (for example "AX")
   my $subcountry = eval { new Locale::SubCountry($country) }
 
   #it could throw a fatal "Invalid country code" error (for example "AX")
   my $subcountry = eval { new Locale::SubCountry($country) }
@@ -378,6 +508,49 @@ sub states_hash {
        @states;
 }
 
        @states;
 }
 
+=item counties STATE COUNTRY
+
+Returns a list of counties for this state and country.
+
+=cut
+
+sub counties {
+  my( $state, $country ) = @_;
+
+  map { $_ } #return num_counties($state, $country) unless wantarray;
+  sort map { s/[\n\r]//g; $_; }
+       map { $_->county }
+           qsearch({
+             'select'  => 'DISTINCT county',
+             'table'   => 'cust_main_county',
+             'hashref' => { 'state'   => $state,
+                            'country' => $country,
+                          },
+           });
+}
+
+=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
 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
 
 =cut
@@ -409,7 +582,7 @@ sub state_label {
 
 Returns a hash reference of the accepted credit card types.  Keys are shorter
 identifiers and values are the longer strings used by the system (see
 
 Returns a hash reference of the accepted credit card types.  Keys are shorter
 identifiers and values are the longer strings used by the system (see
-L<Business::CreditCard).
+L<Business::CreditCard>).
 
 =cut
 
 
 =cut
 
@@ -446,6 +619,274 @@ sub card_types {
   \%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.
+FILENAME does not contain the .tex suffix and is unlinked by this function.
+
+=cut
+
+use String::ShellQuote;
+
+sub generate_ps {
+  my $file = shift;
+
+  my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+  chdir($dir);
+
+  _pslatex($file);
+
+  system('dvips', '-q', '-t', 'letter', "$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");
+
+  my $ps = '';
+
+  if ( $conf->exists('lpr-postscript_prefix') ) {
+    my $prefix = $conf->config('lpr-postscript_prefix');
+    $ps .= eval qq("$prefix");
+  }
+
+  while (<POSTSCRIPT>) {
+    $ps .= $_;
+  }
+
+  close POSTSCRIPT;
+
+  if ( $conf->exists('lpr-postscript_suffix') ) {
+    my $suffix = $conf->config('lpr-postscript_suffix');
+    $ps .= eval qq("$suffix");
+  }
+
+  return $ps;
+
+}
+
+=item generate_pdf FILENAME
+
+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 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" );
+  system(
+    "dvips -q -t letter -f $sfile.dvi ".
+    "| 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");
+
+  my $pdf = '';
+  while (<PDF>) {
+    $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 die "pslatex $file.tex failed; see $file.log for details?\n";
+
+  }
+
+}
+
+=item print ARRAYREF
+
+Sends the lines in ARRAYREF to the printer.
+
+=cut
+
+sub do_print {
+  my $data = shift;
+
+  my $lpr = $conf->config('lpr');
+
+  my $outerr = '';
+  run3 $lpr, $data, \$outerr, \$outerr;
+  if ( $? ) {
+    $outerr = ": $outerr" if length($outerr);
+    die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
+  }
+
+}
+
+=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;
+
+  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/, <OUT> );
+
+  foreach (@lines) { s/\.c0m\s*$/.com/; }
+
+  @lines;
+}
+
 =back
 
 =head1 BUGS
 =back
 
 =head1 BUGS