File Coverage

blib/lib/GPS/Babel.pm
Criterion Covered Total %
statement 10 12 83.3
branch n/a
condition n/a
subroutine 4 4 100.0
pod n/a
total 14 16 87.5


line stmt bran cond sub pod time code
1             package GPS::Babel;
2              
3 5     5   65861 use warnings;
  5         18  
  5         168  
4 5     5   27 use strict;
  5         11  
  5         159  
5 5     5   27 use Carp;
  5         14  
  5         492  
6 5     5   8026 use Geo::Gpx 0.15;
  0            
  0            
7             use File::Which qw(which);
8             use IO::Handle;
9             use Scalar::Util qw(blessed);
10              
11             our $VERSION = '0.11';
12              
13             my $EXENAME = 'gpsbabel';
14              
15             sub new {
16             my $class = shift;
17             my $args = shift || {};
18             my $self = bless {}, $class;
19              
20             if ( exists $args->{exename} ) {
21             my $exename = delete $args->{exename};
22             $exename = [$exename] unless ref $exename eq 'ARRAY';
23             $self->set_exename( @$exename );
24             }
25             else {
26             $self->set_exename( which( $EXENAME ) || () );
27             }
28              
29             return $self;
30             }
31              
32             sub get_exename {
33             my $self = shift;
34             return @{ $self->{exepath} };
35             }
36              
37             sub set_exename {
38             my $self = shift;
39             $self->{exepath} = [@_];
40             }
41              
42             sub check_exe {
43             my $self = shift;
44              
45             my @exe = $self->get_exename;
46             croak "$EXENAME not found" unless @exe;
47             return @exe;
48             }
49              
50             sub _with_babel {
51             my $self = shift;
52             my ( $mode, $opts, $cb ) = @_;
53              
54             my @exe = $self->check_exe;
55             my $exe_desc = "'" . join( "' '", @exe ) . "'";
56              
57             my @args = ( @exe, @{$opts} );
58              
59             if ( $^O =~ /MSWin32/ ) {
60             # Windows: shell escape and collapse to a single string
61             @args = ( '"' . join( '" "', map { s/"/""/g } @args ) . '"' );
62             }
63              
64             open( my $fh, $mode, @args )
65             or die "Can't execute $exe_desc ($!)\n";
66             $cb->( $fh );
67             $fh->close or die "$exe_desc failed ($?)\n";
68             }
69              
70             sub _with_babel_reader {
71             my $self = shift;
72             my ( $opts, $cb ) = @_;
73              
74             $self->_with_babel( '-|', $opts, $cb );
75             }
76              
77             sub _with_babel_lines {
78             my $self = shift;
79             my ( $opts, $cb ) = @_;
80             my @buf = ();
81             my $flush = sub {
82             my $line = join '', @buf;
83             $cb->( $line ) unless $line =~ /^\s*$/;
84             @buf = ();
85             };
86             $self->_with_babel_reader(
87             $opts,
88             sub {
89             my $fh = shift;
90             while ( defined( my $line = <$fh> ) ) {
91             chomp $line;
92             $flush->() unless $line =~ /^\s+/;
93             push @buf, $line;
94             }
95             }
96             );
97             $flush->();
98             }
99              
100             sub _with_babel_writer {
101             my $self = shift;
102             my ( $opts, $cb ) = @_;
103              
104             $self->_with_babel( '|-', $opts, $cb );
105             }
106              
107             sub _tidy {
108             my $str = shift;
109             $str = '' unless defined $str;
110             $str =~ s/^\s+//;
111             $str =~ s/\s+$//;
112             $str =~ s/\s+/ /g;
113             return $str;
114             }
115              
116             sub _find_info {
117             my $self = shift;
118              
119             my $info = {
120             formats => {},
121             filters => {},
122             for_ext => {}
123             };
124              
125             # Read the version
126             $self->_with_babel_reader(
127             ['-V'],
128             sub {
129             my $fh = shift;
130             local $/;
131             $info->{banner} = _tidy( <$fh> );
132             }
133             );
134              
135             if ( $info->{banner} =~ /([\d.]+)/ ) {
136             $info->{version} = $1;
137             }
138             else {
139             $info->{version} = '0.0.0';
140             }
141              
142             my $handle_extra = sub {
143             my @extra = @_;
144             return unless @extra;
145             my $doclink = shift @extra;
146             return (
147             doclink => $doclink,
148             @extra ? ( extra => \@extra ) : ()
149             );
150             };
151              
152             # -^3 and -%1 are 1.2.8 and later
153             if ( _cmp_ver( $info->{version}, '1.2.8' ) >= 0 ) {
154              
155             # File formats
156             $self->_with_babel_lines(
157             ['-^3'],
158             sub {
159             my $ln = shift;
160             my ( $type, @f ) = split( /\t/, $ln );
161             if ( $type eq 'file' ) {
162             my ( $modes, $name, $ext, $desc, $parent, @extra, ) = @f;
163             ( my $nmodes = $modes ) =~ tr/rw-/110/;
164             $nmodes = oct( '0b' . $nmodes );
165             $info->{formats}->{$name} = {
166             modes => $modes,
167             nmodes => $nmodes,
168             desc => $desc,
169             parent => $parent,
170             $handle_extra->( @extra ),
171             };
172             if ( $ext ) {
173             $ext =~ s/^[.]//; # At least one format has a stray '.'
174             $ext = lc( $ext );
175             $info->{formats}->{$name}->{ext} = $ext;
176             push @{ $info->{for_ext}->{$ext} }, $name;
177             }
178             }
179             elsif ( $type eq 'option' ) {
180             my ( $fname, $name, $desc, $type, $default, $min, $max,
181             @extra, )
182             = @f;
183             $info->{formats}->{$fname}->{options}->{$name} = {
184             desc => $desc,
185             type => $type,
186             default => $default || '',
187             min => $min || '',
188             max => $max || '',
189             $handle_extra->( @extra ),
190             };
191             }
192             else {
193              
194             # Something we don't know about - so ignore it
195             }
196             }
197             );
198              
199             # Filters
200             $self->_with_babel_lines(
201             ['-%1'],
202             sub {
203             my $ln = shift;
204             my ( $name, @f ) = split( /\t/, $ln );
205             if ( $name eq 'option' ) {
206             my ( $fname, $oname, $desc, $type, @extra ) = @f;
207             my @valid = splice @extra, 0, 3;
208             $info->{filters}->{$fname}->{options}->{$oname} = {
209             desc => $desc,
210             type => $type,
211             valid => \@valid,
212             $handle_extra->( @extra ),
213             };
214             }
215             else {
216             $info->{filters}->{$name} = { desc => $f[0] };
217             }
218             }
219             );
220             }
221              
222             return $info;
223             }
224              
225             sub get_info {
226             my $self = shift;
227              
228             return $self->{info} ||= $self->_find_info;
229             }
230              
231             sub banner {
232             my $self = shift;
233             return $self->get_info->{banner};
234             }
235              
236             sub version {
237             my $self = shift;
238             return $self->get_info->{version};
239             }
240              
241             sub _cmp_ver {
242             my ( $v1, $v2 ) = @_;
243             my @v1 = split( /[.]/, $v1 );
244             my @v2 = split( /[.]/, $v2 );
245              
246             while ( @v1 && @v2 ) {
247             my $cmp = ( shift @v1 <=> shift @v2 );
248             return $cmp if $cmp;
249             }
250              
251             return @v1 <=> @v2;
252             }
253              
254             sub got_ver {
255             my $self = shift;
256             my $need = shift;
257             my $got = $self->version;
258             return _cmp_ver( $got, $need ) >= 0;
259             }
260              
261             sub guess_format {
262             my $self = shift;
263             my $name = shift;
264             my $dfmt = shift;
265              
266             croak( "Missing filename" )
267             unless defined( $name );
268              
269             my $info = $self->get_info;
270              
271             # Format specified
272             if ( defined( $dfmt ) ) {
273             croak( "Unknown format \"$dfmt\"" )
274             if %{ $info->{formats} }
275             && !exists( $info->{formats}->{$dfmt} );
276             return $dfmt;
277             }
278              
279             croak( "Filename \"$name\" has no extension" )
280             unless $name =~ /[.]([^.]+)$/;
281              
282             my $ext = lc( $1 );
283             my $fmt = $info->{for_ext}->{$ext};
284              
285             croak( "No format handles extension .$ext" )
286             unless defined( $fmt );
287              
288             my @fmt = sort @{$fmt};
289              
290             return $fmt[0] if @fmt == 1;
291              
292             my $last = pop @fmt;
293             my $list = join( ' and ', join( ', ', @fmt ), $last );
294              
295             croak( "Multiple formats ($list) handle extension .$ext" );
296             }
297              
298             sub _convert_opts {
299             my $self = shift;
300             my $inf = shift;
301             my $outf = shift;
302             my $opts = shift || {};
303              
304             croak "Must provide input and output filenames"
305             unless defined( $outf );
306              
307             my $infmt = $self->guess_format( $inf, $opts->{in_format} );
308             my $outfmt = $self->guess_format( $outf, $opts->{out_format} );
309              
310             my $info = $self->get_info;
311              
312             my $inmd = $info->{formats}->{$infmt}->{nmodes} || 0b111111;
313             my $outmd = $info->{formats}->{$outfmt}->{nmodes} || 0b111111;
314              
315             # Work out which modes can be read by the input format /and/ written by
316             # the output format.
317             my $canmd = ( $inmd >> 1 ) & $outmd;
318              
319             my @proc = ();
320             push @proc, '-r' if ( $canmd & 0x01 );
321             push @proc, '-t' if ( $canmd & 0x04 );
322             push @proc, '-w' if ( $canmd & 0x10 );
323              
324             croak
325             "Formats $infmt and $outfmt have no read/write capabilities in common"
326             unless @proc;
327              
328             my @opts = (
329             '-p', '', @proc, '-i', $infmt, '-f',
330             $inf, '-o', $outfmt, '-F', $outf
331             );
332              
333             return @opts;
334             }
335              
336             sub convert {
337             my $self = shift;
338              
339             my @opts = $self->_convert_opts( @_ );
340              
341             $self->direct( @opts );
342             }
343              
344             sub direct {
345             my $self = shift;
346              
347             if ( system( $self->check_exe, @_ ) ) {
348             croak( "$EXENAME failed with error " . ( ( $? == -1 ) ? $! : $? ) );
349             }
350             }
351              
352             sub read {
353             my $self = shift;
354             my $inf = shift;
355             my $opts = shift || {};
356              
357             require Geo::Gpx;
358              
359             croak "Must provide an input filename"
360             unless defined( $inf );
361              
362             $opts->{out_format} = 'gpx';
363              
364             my @opts = $self->_convert_opts( $inf, '-', $opts );
365             my $gpx = undef;
366              
367             $self->_with_babel_reader(
368             \@opts,
369             sub {
370             my $fh = shift;
371             $gpx = Geo::Gpx->new( input => $fh );
372             }
373             );
374              
375             return $gpx;
376             }
377              
378             sub write {
379             my $self = shift;
380             my $outf = shift;
381             my $gpx = shift;
382             my $opts = shift || {};
383              
384             croak "Must provide some data to output"
385             unless blessed( $gpx ) && $gpx->can( 'xml' );
386              
387             $opts->{in_format} = 'gpx';
388              
389             my $xml = $gpx->xml;
390              
391             my @opts = $self->_convert_opts( '-', $outf, $opts );
392             $self->_with_babel_writer(
393             \@opts,
394             sub {
395             my $fh = shift;
396             $fh->print( $xml );
397             }
398             );
399             }
400              
401             1;
402             __END__