add customer fields option with agent, display_custnum, status and name, RT#73721
[freeside.git] / FS / FS / Misc.pm
1 package FS::Misc;
2
3 use strict;
4 use vars qw ( @ISA @EXPORT_OK $DEBUG );
5 use Exporter;
6 use Carp;
7 use Data::Dumper;
8 use IPC::Run qw( run timeout );   # for _pslatex
9 use IPC::Run3; # for do_print... should just use IPC::Run i guess
10 use File::Temp;
11 use Tie::IxHash;
12 use Encode;
13 #do NOT depend on any FS:: modules here, causes weird (sometimes unreproducable
14 #until on client machine) dependancy loops.  put them in FS::Misc::Something
15 #instead
16
17 @ISA = qw( Exporter );
18 @EXPORT_OK = qw( send_email generate_email send_fax
19                  states_hash counties cities state_label
20                  card_types
21                  pkg_freqs
22                  generate_ps generate_pdf do_print
23                  csv_from_fixed
24                  ocr_image
25                  bytes_substr
26                  money_pretty
27                );
28
29 $DEBUG = 0;
30
31 =head1 NAME
32
33 FS::Misc - Miscellaneous subroutines
34
35 =head1 SYNOPSIS
36
37   use FS::Misc qw(send_email);
38
39   send_email();
40
41 =head1 DESCRIPTION
42
43 Miscellaneous subroutines.  This module contains miscellaneous subroutines
44 called from multiple other modules.  These are not OO or necessarily related,
45 but are collected here to eliminate code duplication.
46
47 =head1 SUBROUTINES
48
49 =over 4
50
51 =item send_email OPTION => VALUE ...
52
53 Options:
54
55 =over 4
56
57 =item from
58
59 (required)
60
61 =item to
62
63 (required) comma-separated scalar or arrayref of recipients
64
65 =item subject
66
67 (required)
68
69 =item content-type
70
71 (optional) MIME type for the body
72
73 =item body
74
75 (required unless I<nobody> is true) arrayref of body text lines
76
77 =item mimeparts
78
79 (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().
80
81 =item nobody
82
83 (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,
84 I<content-type>, if specified, overrides the default "multipart/mixed" for the outermost MIME container.
85
86 =item content-encoding
87
88 (optional) when using nobody, optional top-level MIME
89 encoding which, if specified, overrides the default "7bit".
90
91 =item type
92
93 (optional) type parameter for multipart/related messages
94
95 =item custnum
96
97 (optional) L<FS::cust_main> key; if passed, the message will be logged
98 (if logging is enabled) with this custnum.
99
100 =item msgnum
101
102 (optional) L<FS::msg_template> key, for logging.
103
104 =back
105
106 =cut
107
108 use vars qw( $conf );
109 use Date::Format;
110 use MIME::Entity;
111 use Email::Sender::Simple qw(sendmail);
112 use Email::Sender::Transport::SMTP;
113 use Email::Sender::Transport::SMTP::TLS 0.11;
114 use FS::UID;
115
116 FS::UID->install_callback( sub {
117   $conf = new FS::Conf;
118 } );
119
120 sub send_email {
121   my(%options) = @_;
122   if ( $DEBUG ) {
123     my %doptions = %options;
124     $doptions{'body'} = '(full body not shown in debug)';
125     warn "FS::Misc::send_email called with options:\n  ". Dumper(\%doptions);
126 #         join("\n", map { "  $_: ". $options{$_} } keys %options ). "\n"
127   }
128
129   my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
130
131   my @mimeargs = ();
132   my @mimeparts = ();
133   if ( $options{'nobody'} ) {
134
135     croak "'mimeparts' option required when 'nobody' option given\n"
136       unless $options{'mimeparts'};
137
138     @mimeparts = @{$options{'mimeparts'}};
139
140     @mimeargs = (
141       'Type'         => ( $options{'content-type'} || 'multipart/mixed' ),
142       'Encoding'     => ( $options{'content-encoding'} || '7bit' ),
143     );
144
145   } else {
146
147     @mimeparts = @{$options{'mimeparts'}}
148       if ref($options{'mimeparts'}) eq 'ARRAY';
149
150     if (scalar(@mimeparts)) {
151
152       @mimeargs = (
153         'Type'     => 'multipart/mixed',
154         'Encoding' => '7bit',
155       );
156   
157       unshift @mimeparts, { 
158         'Type'        => ( $options{'content-type'} || 'text/plain' ),
159         'Charset'     => 'UTF-8',
160         'Data'        => $options{'body'},
161         'Encoding'    => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
162         'Disposition' => 'inline',
163       };
164
165     } else {
166     
167       @mimeargs = (
168         'Type'     => ( $options{'content-type'} || 'text/plain' ),
169         'Data'     => $options{'body'},
170         'Encoding' => ( $options{'content-type'} ? '-SUGGEST' : '7bit' ),
171         'Charset'  => 'UTF-8',
172       );
173
174     }
175
176   }
177
178   my $from = $options{from};
179   $from =~ s/^\s*//; $from =~ s/\s*$//;
180   if ( $from =~ /^(.*)\s*<(.*@.*)>$/ ) {
181     # a common idiom
182     $from = $2;
183   }
184
185   my $domain;
186   if ( $from =~ /\@([\w\.\-]+)/ ) {
187     $domain = $1;
188   } else {
189     warn 'no domain found in invoice from address '. $options{'from'}.
190          '; constructing Message-ID (and saying HELO) @example.com'; 
191     $domain = 'example.com';
192   }
193   my $message_id = join('.', rand()*(2**32), $$, time). "\@$domain";
194
195   my $time = time;
196   my $message = MIME::Entity->build(
197     'From'       => $options{'from'},
198     'To'         => join(', ', @to),
199     'Sender'     => $options{'from'},
200     'Reply-To'   => $options{'from'},
201     'Date'       => time2str("%a, %d %b %Y %X %z", $time),
202     'Subject'    => Encode::encode('MIME-Header', $options{'subject'}),
203     'Message-ID' => "<$message_id>",
204     @mimeargs,
205   );
206
207   if ( $options{'type'} ) {
208     #false laziness w/cust_bill::generate_email
209     $message->head->replace('Content-type',
210       $message->mime_type.
211       '; boundary="'. $message->head->multipart_boundary. '"'.
212       '; type='. $options{'type'}
213     );
214   }
215
216   foreach my $part (@mimeparts) {
217
218     if ( UNIVERSAL::isa($part, 'MIME::Entity') ) {
219
220       warn "attaching MIME part from MIME::Entity object\n"
221         if $DEBUG;
222       $message->add_part($part);
223
224     } elsif ( ref($part) eq 'HASH' ) {
225
226       warn "attaching MIME part from hashref:\n".
227            join("\n", map "  $_: ".$part->{$_}, keys %$part ). "\n"
228         if $DEBUG;
229       $message->attach(%$part);
230
231     } else {
232       croak "mimepart $part isn't a hashref or MIME::Entity object!";
233     }
234
235   }
236
237   #send the email
238
239   my %smtp_opt = ( 'host' => $conf->config('smtpmachine'),
240                    'helo' => $domain,
241                  );
242
243   my($port, $enc) = split('-', ($conf->config('smtp-encryption') || '25') );
244   $smtp_opt{'port'} = $port;
245
246   my $transport;
247   if ( defined($enc) && $enc eq 'starttls' ) {
248     $smtp_opt{$_} = $conf->config("smtp-$_") for qw(username password);
249     $transport = Email::Sender::Transport::SMTP::TLS->new( %smtp_opt );
250   } else {
251     if ( $conf->exists('smtp-username') && $conf->exists('smtp-password') ) {
252       $smtp_opt{"sasl_$_"} = $conf->config("smtp-$_") for qw(username password);
253     }
254     $smtp_opt{'ssl'} = 1 if defined($enc) && $enc eq 'tls';
255     $transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
256   }
257   
258   push @to, $options{bcc} if defined($options{bcc});
259   local $@; # just in case
260   eval { sendmail($message, { transport => $transport,
261                               from      => $from,
262                               to        => \@to }) };
263
264   my $error = '';
265   if(ref($@) and $@->isa('Email::Sender::Failure')) {
266     $error = $@->code.' ' if $@->code;
267     $error .= $@->message;
268   }
269   else {
270     $error = $@;
271   }
272
273   # Logging
274   if ( $conf->exists('log_sent_mail') ) {
275     my $cust_msg = FS::cust_msg->new({
276         'env_from'  => $options{'from'},
277         'env_to'    => join(', ', @to),
278         'header'    => $message->header_as_string,
279         'body'      => $message->body_as_string,
280         '_date'     => $time,
281         'error'     => $error,
282         'custnum'   => $options{'custnum'},
283         'msgnum'    => $options{'msgnum'},
284         'status'    => ($error ? 'failed' : 'sent'),
285         'msgtype'   => $options{'msgtype'},
286     });
287     my $log_error = $cust_msg->insert;
288     warn "Error logging message: $log_error\n" if $log_error; # at least warn
289   }
290   $error;
291    
292 }
293
294 =item generate_email OPTION => VALUE ...
295
296 Options:
297
298 =over 4
299
300 =item from
301
302 Sender address, required
303
304 =item to
305
306 Recipient address, required
307
308 =item bcc
309
310 Blind copy address, optional
311
312 =item subject
313
314 email subject, required
315
316 =item html_body
317
318 Email body (HTML alternative).  Arrayref of lines, or scalar.
319
320 Will be placed inside an HTML <BODY> tag.
321
322 =item text_body
323
324 Email body (Text alternative).  Arrayref of lines, or scalar.
325
326 =item custnum, msgnum (optional)
327
328 Customer and template numbers, passed through to send_email for logging.
329
330 =back
331
332 Constructs a multipart message from text_body and html_body.
333
334 =cut
335
336 #false laziness w/FS::cust_bill::generate_email
337
338 use MIME::Entity;
339 use HTML::Entities;
340
341 sub generate_email {
342   my %args = @_;
343
344   my $me = '[FS::Misc::generate_email]';
345
346   my @fields = qw(from to bcc subject custnum msgnum msgtype);
347   my %return;
348   @return{@fields} = @args{@fields};
349
350   warn "$me creating HTML/text multipart message"
351     if $DEBUG;
352
353   $return{'nobody'} = 1;
354
355   my $alternative = build MIME::Entity
356     'Type'        => 'multipart/alternative',
357     'Encoding'    => '7bit',
358     'Disposition' => 'inline'
359   ;
360
361   my $data;
362   if ( ref($args{'text_body'}) eq 'ARRAY' ) {
363     $data = join("\n", @{ $args{'text_body'} });
364   } else {
365     $data = $args{'text_body'};
366   }
367
368   $alternative->attach(
369     'Type'        => 'text/plain',
370     'Encoding'    => 'quoted-printable',
371     'Charset'     => 'UTF-8',
372     #'Encoding'    => '7bit',
373     'Data'        => $data,
374     'Disposition' => 'inline',
375   );
376
377   my @html_data;
378   if ( ref($args{'html_body'}) eq 'ARRAY' ) {
379     @html_data = @{ $args{'html_body'} };
380   } else {
381     @html_data = split(/\n/, $args{'html_body'});
382   }
383
384   $alternative->attach(
385     'Type'        => 'text/html',
386     'Encoding'    => 'quoted-printable',
387     'Data'        => [ '<html>',
388                        '  <head>',
389                        '    <title>',
390                        '      '. encode_entities($return{'subject'}), 
391                        '    </title>',
392                        '  </head>',
393                        '  <body bgcolor="#ffffff">',
394                        @html_data,
395                        '  </body>',
396                        '</html>',
397                      ],
398     'Disposition' => 'inline',
399     #'Filename'    => 'invoice.pdf',
400   );
401
402   #no other attachment:
403   # multipart/related
404   #   multipart/alternative
405   #     text/plain
406   #     text/html
407
408   $return{'content-type'} = 'multipart/related';
409   $return{'mimeparts'} = [ $alternative ];
410   $return{'type'} = 'multipart/alternative'; #Content-Type of first part...
411   #$return{'disposition'} = 'inline';
412
413   %return;
414
415 }
416
417 =item send_fax OPTION => VALUE ...
418
419 Options:
420
421 I<dialstring> - (required) 10-digit phone number w/ area code
422
423 I<docdata> - (required) Array ref containing PostScript or TIFF Class F document
424
425 -or-
426
427 I<docfile> - (required) Filename of PostScript TIFF Class F document
428
429 ...any other options will be passed to L<Fax::Hylafax::Client::sendfax>
430
431
432 =cut
433
434 sub send_fax {
435
436   my %options = @_;
437
438   die 'HylaFAX support has not been configured.'
439     unless $conf->exists('hylafax');
440
441   eval {
442     require Fax::Hylafax::Client;
443   };
444
445   if ($@) {
446     if ($@ =~ /^Can't locate Fax.*/) {
447       die "You must have Fax::Hylafax::Client installed to use invoice faxing."
448     } else {
449       die $@;
450     }
451   }
452
453   my %hylafax_opts = map { split /\s+/ } $conf->config('hylafax');
454
455   die 'Called send_fax without a \'dialstring\'.'
456     unless exists($options{'dialstring'});
457
458   if (exists($options{'docdata'}) and ref($options{'docdata'}) eq 'ARRAY') {
459       my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
460       my $fh = new File::Temp(
461         TEMPLATE => 'faxdoc.'. $options{'dialstring'} . '.XXXXXXXX',
462         DIR      => $dir,
463         UNLINK   => 0,
464       ) or die "can't open temp file: $!\n";
465
466       $options{docfile} = $fh->filename;
467
468       print $fh @{$options{'docdata'}};
469       close $fh;
470
471       delete $options{'docdata'};
472   }
473
474   die 'Called send_fax without a \'docfile\' or \'docdata\'.'
475     unless exists($options{'docfile'});
476
477   #FIXME: Need to send canonical dialstring to HylaFAX, but this only
478   #       works in the US.
479
480   $options{'dialstring'} =~ s/[^\d\+]//g;
481   if ($options{'dialstring'} =~ /^\d{10}$/) {
482     $options{dialstring} = '+1' . $options{'dialstring'};
483   } else {
484     return 'Invalid dialstring ' . $options{'dialstring'} . '.';
485   }
486
487   my $faxjob = &Fax::Hylafax::Client::sendfax(%options, %hylafax_opts);
488
489   if ($faxjob->success) {
490     warn "Successfully queued fax to '$options{dialstring}' with jobid " .
491            $faxjob->jobid
492       if $DEBUG;
493     return '';
494   } else {
495     return 'Error while sending FAX: ' . $faxjob->trace;
496   }
497
498 }
499
500 =item states_hash COUNTRY
501
502 Returns a list of key/value pairs containing state (or other sub-country
503 division) abbriviations and names.
504
505 =cut
506
507 use FS::Record qw(qsearch);
508 use Locale::SubCountry;
509
510 sub states_hash {
511   my($country) = @_;
512
513   #a hash?  not expecting an explosion of business from unrecognized countries..
514   return states_hash_nosubcountry($country) if $country eq 'XC';
515
516   my @states = 
517 #     sort
518      map { s/[\n\r]//g; $_; }
519      map { $_->state; }
520          qsearch({ 
521                    'select'    => 'state',
522                    'table'     => 'cust_main_county',
523                    'hashref'   => { 'country' => $country },
524                    'extra_sql' => 'GROUP BY state',
525                 });
526
527   #it could throw a fatal "Invalid country code" error (for example "AX")
528   my $subcountry = eval { new Locale::SubCountry($country) }
529     or return (); # ( '', '(n/a)' );
530
531   #"i see your schwartz is as big as mine!"
532   map  { ( $_->[0] => $_->[1] ) }
533   sort { $a->[1] cmp $b->[1] }
534   map  { [ $_ => state_label($_, $subcountry) ] }
535        @states;
536 }
537
538 sub states_hash_nosubcountry {
539   my($country) = @_;
540
541   my @states = 
542 #     sort
543      map { s/[\n\r]//g; $_; }
544      map { $_->state; }
545          qsearch({ 
546                    'select'    => 'state',
547                    'table'     => 'cust_main_county',
548                    'hashref'   => { 'country' => $country },
549                    'extra_sql' => 'GROUP BY state',
550                 });
551
552   #"i see your schwartz is as big as mine!"
553   map  { ( $_->[0] => $_->[1] ) }
554   sort { $a->[1] cmp $b->[1] }
555   map  { [ $_ => $_ ] }
556        @states;
557 }
558
559 =item counties STATE COUNTRY
560
561 Returns a list of counties for this state and country.
562
563 =cut
564
565 sub counties {
566   my( $state, $country ) = @_;
567
568   map { $_ } #return num_counties($state, $country) unless wantarray;
569   sort map { s/[\n\r]//g; $_; }
570        map { $_->county }
571            qsearch({
572              'select'  => 'DISTINCT county',
573              'table'   => 'cust_main_county',
574              'hashref' => { 'state'   => $state,
575                             'country' => $country,
576                           },
577            });
578 }
579
580 =item cities COUNTY STATE COUNTRY
581
582 Returns a list of cities for this county, state and country.
583
584 =cut
585
586 sub cities {
587   my( $county, $state, $country ) = @_;
588
589   map { $_ } #return num_cities($county, $state, $country) unless wantarray;
590   sort map { s/[\n\r]//g; $_; }
591        map { $_->city }
592            qsearch({
593              'select'  => 'DISTINCT city',
594              'table'   => 'cust_main_county',
595              'hashref' => { 'county'  => $county,
596                             'state'   => $state,
597                             'country' => $country,
598                           },
599            });
600 }
601
602 =item state_label STATE COUNTRY_OR_LOCALE_SUBCOUNRY_OBJECT
603
604 =cut
605
606 sub state_label {
607   my( $state, $country ) = @_;
608
609   unless ( ref($country) ) {
610     $country = eval { new Locale::SubCountry($country) }
611       or return'(n/a)';
612
613   }
614
615   # US kludge to avoid changing existing behaviour 
616   # also we actually *use* the abbriviations...
617   my $full_name = $country->country_code eq 'US'
618                     ? ''
619                     : $country->full_name($state);
620
621   $full_name = '' if $full_name eq 'unknown';
622   $full_name =~ s/\(see also.*\)\s*$//;
623   $full_name .= " ($state)" if $full_name;
624
625   $full_name || $state || '(n/a)';
626
627 }
628
629 =item card_types
630
631 Returns a hash reference of the accepted credit card types.  Keys are shorter
632 identifiers and values are the longer strings used by the system (see
633 L<Business::CreditCard>).
634
635 =cut
636
637 #$conf from above
638
639 sub card_types {
640   my $conf = new FS::Conf;
641
642   my %card_types = (
643     #displayname                    #value (Business::CreditCard)
644     "VISA"                       => "VISA card",
645     "MasterCard"                 => "MasterCard",
646     "Discover"                   => "Discover card",
647     "American Express"           => "American Express card",
648     "Diner's Club/Carte Blanche" => "Diner's Club/Carte Blanche",
649     "enRoute"                    => "enRoute",
650     "JCB"                        => "JCB",
651     "BankCard"                   => "BankCard",
652     "Switch"                     => "Switch",
653     "Solo"                       => "Solo",
654   );
655   my @conf_card_types = grep { ! /^\s*$/ } $conf->config('card-types');
656   if ( @conf_card_types ) {
657     #perhaps the hash is backwards for this, but this way works better for
658     #usage in selfservice
659     %card_types = map  { $_ => $card_types{$_} }
660                   grep {
661                          my $d = $_;
662                            grep { $card_types{$d} eq $_ } @conf_card_types
663                        }
664                     keys %card_types;
665   }
666
667   \%card_types;
668 }
669
670 =item pkg_freqs
671
672 Returns a hash reference of allowed package billing frequencies.
673
674 =cut
675
676 sub pkg_freqs {
677   tie my %freq, 'Tie::IxHash', (
678     '0'    => '(no recurring fee)',
679     '1h'   => 'hourly',
680     '1d'   => 'daily',
681     '2d'   => 'every two days',
682     '3d'   => 'every three days',
683     '1w'   => 'weekly',
684     '2w'   => 'biweekly (every 2 weeks)',
685     '1'    => 'monthly',
686     '45d'  => 'every 45 days',
687     '2'    => 'bimonthly (every 2 months)',
688     '3'    => 'quarterly (every 3 months)',
689     '4'    => 'every 4 months',
690     '137d' => 'every 4 1/2 months (137 days)',
691     '6'    => 'semiannually (every 6 months)',
692     '12'   => 'annually',
693     '13'   => 'every 13 months (annually +1 month)',
694     '24'   => 'biannually (every 2 years)',
695     '36'   => 'triannually (every 3 years)',
696     '48'   => '(every 4 years)',
697     '60'   => '(every 5 years)',
698     '120'  => '(every 10 years)',
699   ) ;
700   \%freq;
701 }
702
703 =item generate_ps FILENAME
704
705 Returns an postscript rendition of the LaTex file, as a scalar.
706 FILENAME does not contain the .tex suffix and is unlinked by this function.
707
708 =cut
709
710 use String::ShellQuote;
711
712 sub generate_ps {
713   my $file = shift;
714
715   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
716   chdir($dir);
717
718   _pslatex($file);
719
720   my $papersize = $conf->config('papersize') || 'letter';
721
722   system('dvips', '-q', '-t', $papersize, "$file.dvi", '-o', "$file.ps" ) == 0
723     or die "dvips failed";
724
725   open(POSTSCRIPT, "<$file.ps")
726     or die "can't open $file.ps: $! (error in LaTeX template?)\n";
727
728   unlink("$file.dvi", "$file.log", "$file.aux", "$file.ps", "$file.tex")
729     unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
730
731   my $ps = '';
732
733   if ( $conf->exists('lpr-postscript_prefix') ) {
734     my $prefix = $conf->config('lpr-postscript_prefix');
735     $ps .= eval qq("$prefix");
736   }
737
738   while (<POSTSCRIPT>) {
739     $ps .= $_;
740   }
741
742   close POSTSCRIPT;
743
744   if ( $conf->exists('lpr-postscript_suffix') ) {
745     my $suffix = $conf->config('lpr-postscript_suffix');
746     $ps .= eval qq("$suffix");
747   }
748
749   return $ps;
750
751 }
752
753 =item generate_pdf FILENAME
754
755 Returns an PDF rendition of the LaTex file, as a scalar.  FILENAME does not
756 contain the .tex suffix and is unlinked by this function.
757
758 =cut
759
760 use String::ShellQuote;
761
762 sub generate_pdf {
763   my $file = shift;
764
765   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
766   chdir($dir);
767
768   #system('pdflatex', "$file.tex");
769   #system('pdflatex', "$file.tex");
770   #! LaTeX Error: Unknown graphics extension: .eps.
771
772   _pslatex($file);
773
774   my $sfile = shell_quote $file;
775
776   #system('dvipdf', "$file.dvi", "$file.pdf" );
777   my $papersize = $conf->config('papersize') || 'letter';
778
779   system(
780     "dvips -q -f $sfile.dvi -t $papersize ".
781     "| gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfwrite -sOutputFile=$sfile.pdf ".
782     "     -c save pop -"
783   ) == 0
784     or die "dvips | gs failed: $!";
785
786   open(PDF, "<$file.pdf")
787     or die "can't open $file.pdf: $! (error in LaTeX template?)\n";
788
789   unlink("$file.dvi", "$file.log", "$file.aux", "$file.pdf", "$file.tex")
790     unless $FS::CurrentUser::CurrentUser->option('save_tmp_typesetting');
791
792   my $pdf = '';
793   while (<PDF>) {
794     $pdf .= $_;
795   }
796
797   close PDF;
798
799   return $pdf;
800
801 }
802
803 sub _pslatex {
804   my $file = shift;
805
806   #my $sfile = shell_quote $file;
807
808   my @cmd = (
809     'latex',
810     '-interaction=batchmode',
811     '\AtBeginDocument{\RequirePackage{pslatex}}',
812     '\def\PSLATEXTMP{\futurelet\PSLATEXTMP\PSLATEXTMPB}',
813     '\def\PSLATEXTMPB{\ifx\PSLATEXTMP\nonstopmode\else\input\fi}',
814     '\PSLATEXTMP',
815     "$file.tex"
816   );
817
818   my $timeout = 30; #? should be more than enough
819
820   for ( 1, 2 ) {
821
822     local($SIG{CHLD}) = sub {};
823     run( \@cmd, '>'=>'/dev/null', '2>'=>'/dev/null', timeout($timeout) )
824       or warn "bad exit status from pslatex pass $_\n";
825
826   }
827
828   return if -e "$file.dvi" && -s "$file.dvi";
829   die "pslatex $file.tex failed, see $file.log for details?\n";
830
831 }
832
833 =item do_print ARRAYREF [, OPTION => VALUE ... ]
834
835 Sends the lines in ARRAYREF to the printer.
836
837 Options available are:
838
839 =over 4
840
841 =item agentnum
842
843 Uses this agent's 'lpr' configuration setting override instead of the global
844 value.
845
846 =item lpr
847
848 Uses this command instead of the configured lpr command (overrides both the
849 global value and agentnum).
850
851 =cut
852
853 sub do_print {
854   my( $data, %opt ) = @_;
855
856   my $lpr = ( exists($opt{'lpr'}) && $opt{'lpr'} )
857               ? $opt{'lpr'}
858               : $conf->config('lpr', $opt{'agentnum'} );
859
860   my $outerr = '';
861   run3 $lpr, $data, \$outerr, \$outerr;
862   if ( $? ) {
863     $outerr = ": $outerr" if length($outerr);
864     die "Error from $lpr (exit status ". ($?>>8). ")$outerr\n";
865   }
866
867 }
868
869 =item csv_from_fixed, FILEREF COUNTREF, [ LENGTH_LISTREF, [ CALLBACKS_LISTREF ] ]
870
871 Converts the filehandle referenced by FILEREF from fixed length record
872 lines to a CSV file according to the lengths specified in LENGTH_LISTREF.
873 The CALLBACKS_LISTREF refers to a correpsonding list of coderefs.  Each
874 should return the value to be substituted in place of its single argument.
875
876 Returns false on success or an error if one occurs.
877
878 =cut
879
880 sub csv_from_fixed {
881   my( $fhref, $countref, $lengths, $callbacks) = @_;
882
883   eval { require Text::CSV_XS; };
884   return $@ if $@;
885
886   my $ofh = $$fhref;
887   my $unpacker = new Text::CSV_XS;
888   my $total = 0;
889   my $template = join('', map {$total += $_; "A$_"} @$lengths) if $lengths;
890
891   my $dir = "%%%FREESIDE_CACHE%%%/cache.$FS::UID::datasrc";
892   my $fh = new File::Temp( TEMPLATE => "FILE.csv.XXXXXXXX",
893                            DIR      => $dir,
894                            UNLINK   => 0,
895                          ) or return "can't open temp file: $!\n"
896     if $template;
897
898   while ( defined(my $line=<$ofh>) ) {
899     $$countref++;
900     if ( $template ) {
901       my $column = 0;
902
903       chomp $line;
904       return "unexpected input at line $$countref: $line".
905              " -- expected $total but received ". length($line)
906         unless length($line) == $total;
907
908       $unpacker->combine( map { my $i = $column++;
909                                 defined( $callbacks->[$i] )
910                                   ? &{ $callbacks->[$i] }( $_ )
911                                   : $_
912                               } unpack( $template, $line )
913                         )
914         or return "invalid data for CSV: ". $unpacker->error_input;
915
916       print $fh $unpacker->string(), "\n"
917         or return "can't write temp file: $!\n";
918     }
919   }
920
921   if ( $template ) { close $$fhref; $$fhref = $fh }
922
923   seek $$fhref, 0, 0;
924   '';
925 }
926
927 =item ocr_image IMAGE_SCALAR
928
929 Runs OCR on the provided image data and returns a list of text lines.
930
931 =cut
932
933 sub ocr_image {
934   my $logo_data = shift;
935
936   #XXX use conf dir location from Makefile
937   my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
938   my $fh = new File::Temp(
939     TEMPLATE => 'bizcard.XXXXXXXX',
940     SUFFIX   => '.png', #XXX assuming, but should handle jpg, gif, etc. too
941     DIR      => $dir,
942     UNLINK   => 0,
943   ) or die "can't open temp file: $!\n";
944
945   my $filename = $fh->filename;
946
947   print $fh $logo_data;
948   close $fh;
949
950   run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
951     or die "ocroscript recognize failed\n";
952
953   run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
954     or die "ocroscript hocr-to-text failed\n";
955
956   my @lines = split(/\n/, <OUT> );
957
958   foreach (@lines) { s/\.c0m\s*$/.com/; }
959
960   @lines;
961 }
962
963 =item bytes_substr STRING, OFFSET[, LENGTH[, REPLACEMENT] ]
964
965 A replacement for "substr" that counts raw bytes rather than logical 
966 characters. Unlike "bytes::substr", will suppress fragmented UTF-8 characters
967 rather than output them. Unlike real "substr", is not an lvalue.
968
969 =cut
970
971 sub bytes_substr {
972   my ($string, $offset, $length, $repl) = @_;
973   my $bytes = substr(
974     Encode::encode('utf8', $string),
975     $offset,
976     $length,
977     Encode::encode('utf8', $repl)
978   );
979   my $chk = $DEBUG ? Encode::FB_WARN : Encode::FB_QUIET;
980   return Encode::decode('utf8', $bytes, $chk);
981 }
982
983 =item money_pretty
984
985 Accepts a postive or negative numerical value.
986 Returns amount formatted for display,
987 including money character.
988
989 =cut
990
991 sub money_pretty {
992   my $amount = shift;
993   my $money_char = $conf->{'money_char'} || '$';
994   $amount = sprintf("%0.2f",$amount);
995   $amount =~ s/^(-?)/$1$money_char/;
996   return $amount;
997 }
998
999 =back
1000
1001 =head1 BUGS
1002
1003 This package exists.
1004
1005 =head1 SEE ALSO
1006
1007 L<FS::UID>, L<FS::CGI>, L<FS::Record>, the base documentation.
1008
1009 L<Fax::Hylafax::Client>
1010
1011 =cut
1012
1013 1;