File Coverage

blib/lib/PDL/IO/XLSX/Reader.pm
Criterion Covered Total %
statement 12 14 85.7
branch n/a
condition n/a
subroutine 5 5 100.0
pod n/a
total 17 19 89.4


line stmt bran cond sub pod time code
1             package PDL::IO::XLSX::Reader::Relationships;
2 3     3   133 use 5.010;
  3         11  
3 3     3   29 use strict;
  3         6  
  3         107  
4 3     3   19 use warnings;
  3         6  
  3         137  
5              
6 3     3   21 use Carp;
  3         33  
  3         371  
7 3     3   12355 use XML::Parser::Expat;
  0            
  0            
8             use Archive::Zip ();
9             use File::Temp;
10              
11             sub new {
12             my ($class, $zip) = @_;
13              
14             my $self = bless {
15             _relationships => {}, # { => {Target => "...", Type => "..."}, ... }
16             }, $class;
17              
18             my $fh = File::Temp->new( SUFFIX => '.xml.rels');
19              
20             my $handle = $zip->memberNamed('xl/_rels/workbook.xml.rels') or return $self;
21             croak 'Cannot write to: '.$fh->filename if $handle->extractToFileNamed($fh->filename) != Archive::Zip::AZ_OK;
22              
23             my $parser = XML::Parser::Expat->new;
24             $parser->setHandlers(
25             Start => sub { $self->_start(@_) },
26             End => sub { $self->_end(@_) },
27             );
28             $parser->parse($fh);
29              
30             $self;
31             }
32              
33             sub relation_target {
34             my ($self, $rid) = @_;
35             return unless exists $self->{_relationships}->{$rid};
36             my $relation = $self->{_relationships}->{$rid};
37             return $relation->{Target};
38             }
39              
40             sub relation {
41             my ($self, $rid) = @_;
42             return unless exists $self->{_relationships}->{$rid};
43             return $self->{_relationships}->{$rid};
44             }
45              
46             sub _start {
47             my ($self, $parser, $name, %attrs) = @_;
48             $self->{_in_relationships} = 1 if $name eq "Relationships";
49             if ($self->{_in_relationships} && $name eq "Relationship" && $attrs{Id}) {
50             $self->{_relationships}->{$attrs{Id}} = {
51             Target => $attrs{Target},
52             Type => $attrs{Type},
53             };
54             }
55             }
56              
57             sub _end {
58             my ($self, $parser, $name) = @_;
59             $self->{_in_relationships} = 0 if $name eq "Relationships";
60             }
61              
62             package PDL::IO::XLSX::Reader::SharedStrings;
63             use 5.010;
64             use strict;
65             use warnings;
66              
67             use Carp;
68             use XML::Parser::Expat;
69             use Archive::Zip ();
70             use File::Temp;
71              
72             sub new {
73             my ($class, $zip) = @_;
74              
75             my $self = bless {
76             _data => [],
77             _is_string => 0,
78             _is_ph => 0,
79             _buf => '',
80             }, $class;
81              
82             my $fh = File::Temp->new( SUFFIX => '.xml' );
83             my $handle = $zip->memberNamed('xl/sharedStrings.xml') or return $self;
84             croak 'Cannot write to: '.$fh->filename if $handle->extractToFileNamed($fh->filename) != Archive::Zip::AZ_OK;
85              
86             my $parser = XML::Parser::Expat->new;
87             $parser->setHandlers(
88             Start => sub { $self->_start(@_) },
89             End => sub { $self->_end(@_) },
90             Char => sub { $self->_char(@_) },
91             );
92             $parser->parse($fh);
93              
94             $self;
95             }
96              
97             sub count {
98             my ($self) = @_;
99             scalar @{ $self->{_data} };
100             }
101              
102             sub get {
103             my ($self, $index) = @_;
104             $self->{_data}->[$index];
105             }
106              
107             sub _start {
108             my ($self, $parser, $name, %attrs) = @_;
109             $self->{_is_string} = 1 if $name eq 'si';
110             $self->{_is_ph} = 1 if $name eq 'rPh';
111             }
112              
113             sub _end {
114             my ($self, $parser, $name) = @_;
115              
116             if ($name eq 'si') {
117             $self->{_is_string} = 0;
118             push @{ $self->{_data} }, $self->{_buf};
119             $self->{_buf} = '';
120             }
121             $self->{_is_ph} = 0 if $name eq 'rPh';
122             }
123              
124             sub _char {
125             my ($self, $parser, $data) = @_;
126             $self->{_buf} .= $data if $self->{_is_string} && !$self->{_is_ph};
127             }
128              
129             package PDL::IO::XLSX::Reader::Sheet;
130             use 5.010;
131             use strict;
132             use warnings;
133              
134             use Carp;
135             use File::Temp;
136             use XML::Parser::Expat;
137             use Archive::Zip ();
138             use Scalar::Util ();
139              
140             use constant {
141             STYLE_IDX => 'i',
142             STYLE => 's',
143             FMT => 'f',
144             REF => 'r',
145             COLUMN => 'c',
146             VALUE => 'v',
147             TYPE => 't',
148             TYPE_SHARED_STRING => 's',
149             GENERATED_CELL => 'g',
150             };
151              
152             sub new {
153             my ($class, $zip, $target, $shared_strings, $styles, $row_callback) = @_;
154              
155             my $self = bless {
156             _data => '',
157             _is_sheetdata => 0,
158             _row_count => 0,
159             _current_row => [],
160             _cell => undef,
161             _is_value => 0,
162             _row_callback => $row_callback,
163             _shared_strings => $shared_strings,
164             _styles => $styles,
165              
166             }, $class;
167              
168             my $fh = File::Temp->new( SUFFIX => '.xml' );
169             my $handle = $zip->memberNamed("xl/$target");
170             croak 'Cannot write to: '.$fh->filename if $handle->extractToFileNamed($fh->filename) != Archive::Zip::AZ_OK;
171              
172             my $parser = XML::Parser::Expat->new;
173             $parser->setHandlers(
174             Start => sub { $self->_start(@_) },
175             End => sub { $self->_end(@_) },
176             Char => sub { $self->_char(@_) },
177             );
178             $parser->parse($fh);
179              
180             $self;
181             }
182              
183             sub _start {
184             my ($self, $parser, $name, %attrs) = @_;
185              
186             if ($name eq 'sheetData') {
187             $self->{_is_sheetdata} = 1;
188             }
189             elsif ($self->{_is_sheetdata} and $name eq 'row') {
190             $self->{_current_row} = [];
191             }
192             elsif ($name eq 'c') {
193             $self->{_cell} = {
194             STYLE_IDX() => $attrs{ STYLE() },
195             TYPE() => $attrs{ TYPE() },
196             REF() => $attrs{ REF() },
197             COLUMN() => scalar(@{ $self->{_current_row} }) + 1,
198             };
199             }
200             elsif ($name eq 'v') {
201             $self->{_is_value} = 1;
202             }
203             }
204              
205             sub _end {
206             my ($self, $parser, $name) = @_;
207              
208             if ($name eq 'sheetData') {
209             $self->{_is_sheetdata} = 0;
210             }
211             elsif ($self->{_is_sheetdata} and $name eq 'row') {
212             $self->{_row_count}++;
213             $self->{_row_callback}->( delete $self->{_current_row} );
214             }
215             elsif ($name eq 'c') {
216             my $c = $self->{_cell};
217             $self->_parse_rel($c);
218              
219             if (($c->{ TYPE() } || '') eq TYPE_SHARED_STRING()) {
220             my $idx = int($self->{_data});
221             $c->{ VALUE() } = $self->{_shared_strings}->get($idx);
222             }
223             else {
224             $c->{ VALUE() } = $self->{_data};
225             }
226              
227             $c->{ STYLE() } = $self->{_styles}->cell_style( $c->{ STYLE_IDX() } );
228             $c->{ FMT() } = my $cell_type =
229             $self->{_styles}->cell_type_from_style($c->{ STYLE() });
230              
231             my $v = $c->{ VALUE() };
232              
233             if (!defined $c->{ TYPE() }) {
234             # actual value (number or date)
235             if (Scalar::Util::looks_like_number($v)) {
236             $c->{ VALUE() } = $v + 0;
237             }
238             } else {
239             if (!defined $v) {
240             $c->{ VALUE() } = '';
241             }
242             elsif ($cell_type ne 'unicode') {
243             # warn 'not unicode: ' . $cell_type;
244             $c->{ VALUE() } = $v;
245             }
246             }
247              
248             push @{ $self->{_current_row} }, $c;
249              
250             $self->{_data} = '';
251             $self->{_cell} = undef;
252             }
253             elsif ($name eq 'v') {
254             $self->{_is_value} = 0;
255             }
256             }
257              
258             sub _char {
259             my ($self, $parser, $data) = @_;
260              
261             if ($self->{_is_value}) {
262             $self->{_data} .= $data;
263             }
264             }
265              
266             sub _parse_rel {
267             my ($self, $cell) = @_;
268              
269             my ($column, $row) = $cell->{ REF() } =~ /([A-Z]+)(\d+)/;
270              
271             my $v = 0;
272             my $i = 0;
273             for my $ch (split '', $column) {
274             my $s = length($column) - $i++ - 1;
275             $v += (ord($ch) - ord('A') + 1) * (26**$s);
276             }
277              
278             $cell->{ REF() } = [$v, $row];
279              
280             if ($cell->{ COLUMN() } > $v) {
281             croak sprintf 'Detected smaller index than current cell, something is wrong! (row %s): %s <> %s', $row, $v, $cell->{ COLUMN() };
282             }
283              
284             # add omitted cells
285             for ($cell->{ COLUMN() } .. $v-1) {
286             push @{ $self->{_current_row} }, {
287             GENERATED_CELL() => 1,
288             STYLE_IDX() => undef,
289             TYPE() => undef,
290             REF() => [ $_, $row ],
291             COLUMN() => $_,
292             VALUE() => '',
293             FMT() => 'unicode',
294             };
295             }
296             }
297              
298             package PDL::IO::XLSX::Reader::Styles;
299             use 5.010;
300             use strict;
301             use warnings;
302              
303             use Carp;
304             use XML::Parser::Expat;
305             use Archive::Zip ();
306             use File::Temp;
307              
308             use constant BUILTIN_FMT => 0;
309             use constant BUILTIN_TYPE => 1;
310              
311             use constant BUILTIN_NUM_FMTS => [
312             ['@', 'unicode'], # 0x00
313             ['0', 'int'], # 0x01
314             ['0.00', 'float'], # 0x02
315             ['#,##0', 'float'], # 0x03
316             ['#,##0.00', 'float'], # 0x04
317             ['($#,##0_);($#,##0)', 'float'], # 0x05
318             ['($#,##0_);[RED]($#,##0)', 'float'], # 0x06
319             ['($#,##0.00_);($#,##0.00_)', 'float'], # 0x07
320             ['($#,##0.00_);[RED]($#,##0.00_)', 'float'], # 0x08
321             ['0%', 'int'], # 0x09
322             ['0.00%', 'float'], # 0x0a
323             ['0.00E+00', 'float'], # 0x0b
324             ['# ?/?', 'float'], # 0x0c
325             ['# ??/??', 'float'], # 0x0d
326             ['m-d-yy', 'datetime.date'], # 0x0e
327             ['d-mmm-yy', 'datetime.date'], # 0x0f
328             ['d-mmm', 'datetime.date'], # 0x10
329             ['mmm-yy', 'datetime.date'], # 0x11
330             ['h:mm AM/PM', 'datetime.time'], # 0x12
331             ['h:mm:ss AM/PM', 'datetime.time'], # 0x13
332             ['h:mm', 'datetime.time'], # 0x14
333             ['h:mm:ss', 'datetime.time'], # 0x15
334             ['m-d-yy h:mm', 'datetime.datetime'], # 0x16
335             #0x17-0x24 -- Differs in Natinal
336             undef, # 0x17
337             undef, # 0x18
338             undef, # 0x19
339             undef, # 0x1a
340             undef, # 0x1b
341             undef, # 0x1c
342             undef, # 0x1d
343             undef, # 0x1e
344             undef, # 0x1f
345             undef, # 0x20
346             undef, # 0x21
347             undef, # 0x22
348             undef, # 0x23
349             undef, # 0x24
350             ['(#,##0_);(#,##0)', 'int'], # 0x25
351             ['(#,##0_);[RED](#,##0)', 'int'], # 0x26
352             ['(#,##0.00);(#,##0.00)', 'float'], # 0x27
353             ['(#,##0.00);[RED](#,##0.00)', 'float'], # 0x28
354             ['_(*#,##0_);_(*(#,##0);_(*"-"_);_(@_)', 'float'], # 0x29
355             ['_($*#,##0_);_($*(#,##0);_(*"-"_);_(@_)', 'float'], # 0x2a
356             ['_(*#,##0.00_);_(*(#,##0.00);_(*"-"??_);_(@_)', 'float'], # 0x2b
357             ['_($*#,##0.00_);_($*(#,##0.00);_(*"-"??_);_(@_)', 'float'], # 0x2c
358             ['mm:ss', 'datetime.timedelta'], # 0x2d
359             ['[h]:mm:ss', 'datetime.timedelta'], # 0x2e
360             ['mm:ss.0', 'datetime.timedelta'], # 0x2f
361             ['##0.0E+0', 'float'], # 0x30
362             ['@', 'unicode'], # 0x31
363             ];
364              
365             sub new {
366             my ($class, $zip) = @_;
367              
368             my $self = bless {
369             _number_formats => [],
370             _is_cell_xfs => 0,
371             _current_style => undef,
372             }, $class;
373              
374             my $fh = File::Temp->new( SUFFIX => '.xml' );
375              
376             my $handle = $zip->memberNamed('xl/styles.xml');
377             croak 'Cannot write to: '.$fh->filename if $handle->extractToFileNamed($fh->filename) != Archive::Zip::AZ_OK;
378              
379             my $parser = XML::Parser::Expat->new;
380             $parser->setHandlers(
381             Start => sub { $self->_start(@_) },
382             End => sub { $self->_end(@_) },
383             Char => sub { },
384             );
385             $parser->parse($fh);
386              
387             $self;
388             }
389              
390             sub cell_style {
391             my ($self, $style_id) = @_;
392             $style_id ||= 0;
393             $self->{_number_formats}[int $style_id];
394             }
395              
396             sub cell_type_from_style {
397             my ($self, $style) = @_;
398              
399             if ($style->{numFmt} > scalar @{ BUILTIN_NUM_FMTS() }) {
400             return $self->{_num_fmt}{ $style->{numFmt} }{_type} // undef;
401             }
402              
403             BUILTIN_NUM_FMTS->[ $style->{numFmt} ][BUILTIN_TYPE];
404             }
405              
406             sub cell_format_from_style {
407             my ($self, $style) = @_;
408              
409             if ($style->{numFmt} > scalar @{ BUILTIN_NUM_FMTS() }) {
410             return $self->{_num_fmt}{ $style->{numFmt} }{formatCode} // undef;
411             }
412              
413             BUILTIN_NUM_FMTS->[ $style->{numFmt} ][BUILTIN_FMT];
414             }
415              
416             sub _start {
417             my ($self, $parser, $name, %attrs) = @_;
418              
419             if ($name eq 'cellXfs') {
420             $self->{_is_cell_xfs} = 1;
421             }
422             elsif ($self->{_is_cell_xfs} and $name eq 'xf') {
423             $self->{_current_style} = {
424             numFmt => int($attrs{numFmtId}) || 0,
425             exists $attrs{fontId} ? ( font => $attrs{fontId} ) : (),
426             exists $attrs{fillId} ? ( fill => $attrs{fillId} ) : (),
427             exists $attrs{borderId} ? ( border => $attrs{borderId} ) : (),
428             exists $attrs{xfId} ? ( xf => $attrs{xfId} ) : (),
429             exists $attrs{applyFont} ? ( applyFont => $attrs{applyFont} ) : (),
430             exists $attrs{applyNumberFormat} ? ( applyNumFmt => $attrs{applyNumberFormat} ) : (),
431             };
432             }
433             elsif ($name eq 'numFmts') {
434             $self->{_is_num_fmts} = 1;
435             }
436             elsif ($self->{_is_num_fmts} and $name eq 'numFmt'){
437             $self->{_current_numfmt} = {
438             numFmtId => $attrs{numFmtId},
439             exists $attrs{formatCode} ? (
440             formatCode => $attrs{formatCode},
441             _type => $self->_parse_format_code_type($attrs{formatCode}),
442             ) : (),
443             };
444             }
445             }
446              
447             sub _end {
448             my ($self, $parser, $name) = @_;
449              
450             if ($name eq 'cellXfs') {
451             $self->{_is_cell_xfs} = 0;
452             }
453             elsif ($self->{_current_style} and $name eq 'xf') {
454             push @{ $self->{_number_formats } }, delete $self->{_current_style};
455             }
456             elsif ($name eq 'numFmts') {
457             $self->{_is_num_fmts} = 0;
458             }
459             elsif ($self->{_current_numfmt} and $name eq 'numFmt') {
460             my $id = $self->{_current_numfmt}{numFmtId};
461             $self->{_num_fmt}{ $id } = delete $self->{_current_numfmt};
462             }
463             }
464              
465             sub _parse_format_code_type {
466             my ($self, $format_code) = @_;
467             my $type;
468             if ($format_code =~ /(y|m|d|h|s)/) {
469             $type = 'datetime.';
470              
471             $type .= 'date' if $format_code =~ /(y|d)/;
472             $type .= 'time' if $format_code =~ /(h|s)/;
473              
474             $type .= 'date' if $type eq 'datetime.'; # assume as date only specified 'm'
475             } else {
476             $type = 'unicode';
477             }
478             return $type;
479             }
480              
481             package PDL::IO::XLSX::Reader::Workbook;
482             use 5.010;
483             use strict;
484             use warnings;
485              
486             use Carp;
487             use XML::Parser::Expat;
488             use Archive::Zip ();
489             use File::Temp;
490              
491             sub new {
492             my ($class, $zip) = @_;
493             my $self = bless [], $class;
494             my $fh = File::Temp->new( SUFFIX => '.xml' );
495             my $handle = $zip->memberNamed('xl/workbook.xml');
496             croak 'Cannot write to: '.$fh->filename if $handle->extractToFileNamed($fh->filename) != Archive::Zip::AZ_OK;
497             my $parser = XML::Parser::Expat->new;
498             $parser->setHandlers(
499             Start => sub { $self->_start(@_) },
500             End => sub {},
501             Char => sub {},
502             );
503             $parser->parse($fh);
504             $self;
505             }
506              
507             sub names {
508             my ($self) = @_;
509             map { $_->{name} } @$self;
510             }
511              
512             sub sheet_id {
513             my ($self, $name) = @_;
514              
515             my ($meta) = grep { $_->{name} eq $name } @$self
516             or return;
517              
518             if ($meta->{'r:id'}) {
519             (my $r = $meta->{'r:id'}) =~ s/^rId//;
520             return $r;
521             }
522             else {
523             return $meta->{sheetId};
524             }
525             }
526              
527             sub _start {
528             my ($self, $parser, $el, %attr) = @_;
529             push @$self, \%attr if $el eq 'sheet';
530             }
531              
532             package PDL::IO::XLSX::Reader;
533             use 5.010;
534             use strict;
535             use warnings;
536              
537             use Carp;
538              
539             sub new {
540             my ($class, $filename) = @_;
541             my $zip = Archive::Zip->new;
542             croak "Cannot open file: $filename" if $zip->read($filename) != Archive::Zip::AZ_OK;
543             bless {
544             _zip => $zip,
545             _workbook => PDL::IO::XLSX::Reader::Workbook->new($zip),
546             _shared_strings => PDL::IO::XLSX::Reader::SharedStrings->new($zip),
547             _styles => PDL::IO::XLSX::Reader::Styles->new($zip),
548             _relationships => PDL::IO::XLSX::Reader::Relationships->new($zip),
549             }, $class;
550             }
551              
552             sub parse_sheet_by_name {
553             my ($self, $name, $row_callback) = @_;
554             my $id = $self->{_workbook}->sheet_id($name);
555             croak "Non-existing sheet '$name'" if !defined $id;
556             return $self->parse_sheet_by_id($id, $row_callback);
557             }
558              
559             sub parse_sheet_by_id {
560             my ($self, $id, $row_callback) = @_;
561              
562             my $relation = $self->{_relationships}->relation("rId$id");
563             return unless $relation;
564              
565             if ($relation->{Type} eq 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet') {
566             my $target = $relation->{Target};
567             PDL::IO::XLSX::Reader::Sheet->new($self->{_zip}, $target, $self->{_shared_strings}, $self->{_styles}, $row_callback);
568             }
569             }
570              
571             1;
572              
573             __END__