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