File Coverage

blib/lib/Excel/ValueReader/XLSX/Backend/LibXML.pm
Criterion Covered Total %
statement 151 161 93.7
branch 90 100 90.0
condition 41 54 75.9
subroutine 18 18 100.0
pod n/a
total 300 333 90.0


line stmt bran cond sub pod time code
1             package Excel::ValueReader::XLSX::Backend::LibXML;
2 2     2   1654 use utf8;
  2         6  
  2         14  
3 2     2   126 use 5.12.1;
  2         8  
4 2     2   12 use Moose;
  2         3  
  2         15  
5 2     2   11365 use Scalar::Util qw/looks_like_number/;
  2         2  
  2         118  
6 2     2   816 use XML::LibXML::Reader qw/XML_READER_TYPE_END_ELEMENT/;
  2         51041  
  2         193  
7 2     2   14 use Iterator::Simple qw/iter/;
  2         5  
  2         2410  
8              
9             extends 'Excel::ValueReader::XLSX::Backend';
10              
11             #======================================================================
12             # LAZY ATTRIBUTE CONSTRUCTORS
13             #======================================================================
14              
15             sub _strings {
16 8     8   17 my $self = shift;
17              
18 8         25 my $reader = $self->_xml_reader_for_zip_member('xl/sharedStrings.xml');
19              
20 8         18 my @strings;
21             my $last_string;
22             NODE:
23 8         409 while ($reader->read) {
24 2716 100       7670 next NODE if $reader->nodeType == XML_READER_TYPE_END_ELEMENT;
25 1636         3329 my $node_name = $reader->name;
26              
27 1636 100       4294 if ($node_name eq 'si') {
    100          
28 530 100       1252 push @strings, $last_string if defined $last_string;
29 530         1421 $last_string = '';
30             }
31             elsif ($node_name eq '#text') {
32 532         1966 $last_string .= $reader->value;
33             }
34             }
35              
36 8 50       28 push @strings, $last_string if defined $last_string;
37              
38 8         49 return \@strings;
39             }
40              
41              
42             sub _workbook_data {
43 15     15   42 my $self = shift;
44              
45 15         88 my %workbook_data = (sheets => {}, base_year => 1900);
46 15         38 my $sheet_id = 1;
47              
48 15         62 my $reader = $self->_xml_reader_for_zip_member('xl/workbook.xml');
49              
50             NODE:
51 15         1306 while ($reader->read) {
52 469 100       1461 next NODE if $reader->nodeType == XML_READER_TYPE_END_ELEMENT;
53              
54 350 100 100     3525 if ($reader->name eq 'sheet') {
    100 100        
    100          
55 61 50       198 my $name = $reader->getAttribute('name')
56             or die "sheet node without name";
57 61         379 $workbook_data{sheets}{$name} = $sheet_id++;
58             }
59             elsif ($reader->name eq 'workbookPr' and my $date_attr = $reader->getAttribute('date1904')) {
60 4 100 66     38 $workbook_data{base_year} = 1904 if $date_attr eq '1' or $date_attr eq 'true'; # this workbook uses the 1904 calendar
61             }
62             elsif ($reader->name eq 'workbookView' and my $active_attr = $reader->getAttribute('activeTab')) {
63 7 50       55 $workbook_data{active_sheet} = $active_attr + 1 if defined $active_attr;
64             }
65             }
66              
67 15         93 return \%workbook_data;
68             }
69              
70              
71             sub _date_styles {
72 10     10   20 my $self = shift;
73              
74 10         21 state $date_style_regex = qr{[dy]|\bmm\b};
75 10         20 my @date_styles;
76              
77             # read from the styles.xml zip member
78 10         35 my $xml_reader = $self->_xml_reader_for_zip_member('xl/styles.xml');
79              
80             # start with Excel builtin number formats for dates and times
81 10         56 my @numFmt = $self->Excel_builtin_date_formats;
82              
83 10         25 my $expected_subnode = undef;
84              
85             # add other date formats explicitly specified in this workbook
86             NODE:
87 10         471 while ($xml_reader->read) {
88 1458 100       4425 next NODE if $xml_reader->nodeType == XML_READER_TYPE_END_ELEMENT;
89              
90             # special treatment for some specific subtrees -- see 'numFmt' and 'xf' below
91 1118 100       1979 if ($expected_subnode) {
92 386         712 my ($name, $depth, $handler) = @$expected_subnode;
93 386 100 66     1581 if ($xml_reader->name eq $name && $xml_reader->depth == $depth) {
    100          
94             # process that subnode and go to the next node
95 256         543 $handler->();
96 256         1993 next NODE;
97             }
98             elsif ($xml_reader->depth < $depth) {
99             # finished handling subnodes; back to regular node treatment
100 18         102 $expected_subnode = undef;
101             }
102             }
103              
104             # regular node treatement
105 862 100       5701 if ($xml_reader->name eq 'numFmts') {
    100          
106             # start parsing nodes for numeric formats
107             $expected_subnode = [numFmt => $xml_reader->depth+1 => sub {
108 76     76   220 my $id = $xml_reader->getAttribute('numFmtId');
109 76         210 my $code = $xml_reader->getAttribute('formatCode');
110 76 100 33     667 $numFmt[$id] = $code if $id && $code && $code =~ $date_style_regex;
      66        
111 8         217 }];
112             }
113              
114             elsif ($xml_reader->name eq 'cellXfs') {
115             # start parsing nodes for cell formats
116             $expected_subnode = [xf => $xml_reader->depth+1 => sub {
117 180     180   271 state $xf_count = 0;
118 180         515 my $numFmtId = $xml_reader->getAttribute('numFmtId');
119 180         331 my $code = $numFmt[$numFmtId]; # may be undef
120 180         400 $date_styles[$xf_count++] = $code;
121 10         180 }];
122             }
123             }
124              
125 10         87 return \@date_styles;
126             }
127              
128              
129              
130             #======================================================================
131             # METHODS
132             #======================================================================
133              
134             sub _xml_reader {
135 99     99   269 my ($self, $xml) = @_;
136              
137 99         912 my $reader = XML::LibXML::Reader->new(string => $xml,
138             no_blanks => 1,
139             no_network => 1,
140             huge => 1);
141 99         10906 return $reader;
142             }
143              
144              
145             sub _xml_reader_for_zip_member {
146 77     77   192 my ($self, $member_name) = @_;
147              
148 77         309 my $contents = $self->_zip_member_contents($member_name);
149 77         4966 return $self->_xml_reader($contents);
150             }
151              
152              
153             sub _values {
154 44     44   157 my ($self, $sheet, $want_iterator) = @_;
155              
156             # prepare for traversing the XML structure
157 44         2021 my $has_date_formatter = $self->frontend->date_formatter;
158 44         278 my $sheet_member_name = $self->_zip_member_name_for_sheet($sheet);
159 44         197 my $xml_reader = $self->_xml_reader_for_zip_member($sheet_member_name);
160              
161              
162             # get sheet 'ref' attribute from the initial preamble
163 44         98 my $ref;
164             PREAMBLE:
165 44         3244 while ($xml_reader->read) {
166 100 100       719 if ($xml_reader->name eq 'dimension') {
167 44         253 $ref = $xml_reader->getAttribute('ref');
168 44         135 last PREAMBLE;
169             }
170             }
171              
172              
173              
174 44         128 my ($row_num, $col_num, @rows) = (0, 0);
175 44         104 my ($cell_type, $cell_style, $seen_node);
176              
177             # dual closure : may be used as an iterator or as a regular sub, depending on $want_iterator. Of course
178             # it would have been simpler to just write an iterator, and call it in a loop if the client wants all rows
179             # at once ... but thousands of additional sub calls would slow down the process. So this more complex implementation
180             # is for the sake of processing speed.
181             my $get_values = sub {
182              
183             # in iterator mode, if we have a row ready, just return it
184 2100066 100 100 2100066   8402282 return shift @rows if $want_iterator and @rows > 1;
185              
186             # otherwise loop on matching nodes
187             NODE:
188 856         6184 while ($xml_reader->read) {
189 22244         53861 my $node_name = $xml_reader->name;
190 22244         45127 my $node_type = $xml_reader->nodeType;
191              
192 22244 100 50     48227 $xml_reader->finish and last NODE if $node_name eq 'sheetData' && $node_type == XML_READER_TYPE_END_ELEMENT;
      100        
193 22209 100       70954 next NODE if $node_type == XML_READER_TYPE_END_ELEMENT;
194              
195 13178 100       41567 if ($node_name eq 'row') {
    100          
    100          
    100          
196 1746         2886 my $prev_row = $row_num;
197 1746   66     6619 $row_num = $xml_reader->getAttribute('r') // $row_num+1;
198 1746         2905 $col_num = 0;
199 1746         1305959 push @rows, [] for 1 .. $row_num-$prev_row;
200              
201             # in iterator mode, if we have a closed empty row, just return it
202 1746 100 100     12790 return shift @rows if $want_iterator and @rows > 1;
203             }
204              
205             elsif ($node_name eq 'c') {
206 3880   100     15094 my $A1_cell_ref = $xml_reader->getAttribute('r') // '';
207 3880         18012 my ($col_A1, $given_row) = ($A1_cell_ref =~ /^([A-Z]+)(\d+)$/);
208              
209 3880   66     8559 $given_row //= $row_num;
210 3880 50       10716 if ($given_row < $row_num) {die "cell claims to be in row $given_row while current row is $row_num"}
  0 50       0  
211 0         0 elsif ($given_row > $row_num) {push @rows, [] for 1 .. $given_row-$row_num;
212 0         0 $col_num = 0;
213 0         0 $row_num = $given_row;}
214              
215             # deal with the col number given in the 'r' attribute, if present
216 3880 100 33     7277 if ($col_A1) {$col_num = $Excel::ValueReader::XLSX::A1_to_num_memoized{$col_A1}
  3856         10508  
217             //= Excel::ValueReader::XLSX->A1_to_num($col_A1)}
218 24         39 else {$col_num++}
219              
220 3880         10808 $cell_type = $xml_reader->getAttribute('t');
221 3880         8884 $cell_style = $xml_reader->getAttribute('s');
222 3880         15725 $seen_node = '';
223             }
224              
225             elsif ($node_name =~ /^[vtf]$/) {
226             # remember that we have seen a 'value' or 'text' or 'formula' node
227 3608         16263 $seen_node = $node_name;
228             }
229              
230             elsif ($node_name eq '#text') {
231             #start processing cell content
232              
233 3608         9597 my $val = $xml_reader->value;
234 3608   100     10556 $cell_type //= '';
235              
236 3608 100 33     6911 if ($seen_node eq 'v') {
    50          
    50          
237 3578 100       8457 if ($cell_type eq 's') {
    50          
    50          
238 2433 50       7228 if (looks_like_number($val)) {
239 2433         110709 $val = $self->strings->[$val]; # string -- pointer into the global array of shared strings
240             }
241             else {
242 0         0 warn "unexpected non-numerical value: $val inside a node of shape <v t='s'>\n";
243             }
244             }
245             elsif ($cell_type eq 'e') {
246 0         0 $val = undef; # error -- silently replace by undef
247             }
248             elsif ($cell_type =~ /^(n|d|b|str|)$/) {
249             # number, date, boolean, formula string or no type : content is already in $val
250              
251             # if this is a date, replace the numeric value by the formatted date
252 1145 100 100     6853 if ($has_date_formatter && $cell_style && looks_like_number($val) && $val >= 0) {
      100        
      66        
253 486         25902 my $date_style = $self->date_styles->[$cell_style];
254 486 100       1721 $val = $self->formatted_date($val, $date_style) if $date_style;
255             }
256             }
257             else {
258             # handle unexpected cases
259 0         0 warn "unsupported type '$cell_type' in cell L${row_num}C${col_num}\n";
260 0         0 $val = undef;
261             }
262              
263             # insert this value into the last row
264 3578         22812 $rows[-1][$col_num-1] = $val;
265             }
266              
267             elsif ($seen_node eq 't' && $cell_type eq 'inlineStr') {
268             # inline string -- accumulate all #text nodes until next cell
269 2     2   14 no warnings 'uninitialized';
  2         5  
  2         1032  
270 0         0 $rows[-1][$col_num-1] .= $val;
271             }
272              
273             elsif ($seen_node eq 'f') {
274             # formula -- just ignore it
275             }
276              
277             else {
278             # handle unexpected cases
279 0         0 warn "unexpected text node in cell L${row_num}C${col_num}: $val\n";
280             }
281             }
282             }
283              
284             # end of XML nodes. In iterator mode, return a row if we have one
285 48 100       271 return @rows ? shift @rows : undef if $want_iterator;
    100          
286 44         428 };
287              
288             # decide what to return depending on the dual mode
289             my $retval = $want_iterator ? iter($get_values)
290 44 100       222 : do {$get_values->(); \@rows}; # run the closure and return the rows
  22         60  
  22         60  
291              
292 44         1287 return ($ref, $retval);
293             }
294              
295              
296              
297              
298             sub _table_targets {
299 10     10   29 my ($self, $rel_xml) = @_;
300              
301 10         30 my $xml_reader = $self->_xml_reader($rel_xml);
302              
303 10         18 my @table_targets;
304              
305             # iterate through XML nodes
306             NODE:
307 10         301 while ($xml_reader->read) {
308 38         103 my $node_name = $xml_reader->name;
309 38         82 my $node_type = $xml_reader->nodeType;
310 38 100       135 next NODE if $node_type == XML_READER_TYPE_END_ELEMENT;
311              
312 28 100       79 if ($node_name eq 'Relationship') {
313 18         61 my $target = $xml_reader->getAttribute('Target');
314 18 100       183 if ($target =~ m[tables/table(\d+)\.xml]) {
315             # just store the table id (positive integer)
316 12         97 push @table_targets, $1;
317             }
318             }
319             }
320              
321 10         53 return @table_targets;
322             }
323              
324              
325             sub _parse_table_xml {
326 12     12   39 my ($self, $xml) = @_;
327              
328 12         22 my %table_info;
329              
330 12         38 my $xml_reader = $self->_xml_reader($xml);
331              
332             # iterate through XML nodes
333             NODE:
334 12         738 while ($xml_reader->read) {
335 108         260 my $node_name = $xml_reader->name;
336 108         229 my $node_type = $xml_reader->nodeType;
337 108 100       316 next NODE if $node_type == XML_READER_TYPE_END_ELEMENT;
338              
339 84 100       270 if ($node_name eq 'table') {
    100          
340             %table_info = (
341             name => $xml_reader->getAttribute('displayName'),
342             ref => $xml_reader->getAttribute('ref'),
343 12         65 no_headers => do {my $has_headers = $xml_reader->getAttribute('headerRowCount');
  12         31  
344 12 100       263 defined $has_headers && !$has_headers},
345             has_totals => $xml_reader->getAttribute('totalsRowCount'),
346             );
347             }
348             elsif ($node_name eq 'tableColumn') {
349 38         55 push @{$table_info{columns}}, $xml_reader->getAttribute('name');
  38         248  
350             }
351             }
352              
353 12         55 return \%table_info
354             }
355              
356              
357              
358              
359             1;
360              
361              
362             __END__
363              
364              
365             =head1 NAME
366              
367             Excel::ValueReader::XLSX::Backend::LibXML - using LibXML for extracting values from Excel workbooks
368              
369             =head1 DESCRIPTION
370              
371             This is one of two backend modules for L<Excel::ValueReader::XLSX>; the other
372             possible backend is L<Excel::ValueReader::XLSX::Backend::Regex>.
373              
374             This backend parses OOXML structures using L<XML::LibXML::Reader>.
375              
376             =head1 AUTHOR
377              
378             Laurent Dami, E<lt>dami at cpan.orgE<gt>
379              
380             =head1 COPYRIGHT AND LICENSE
381              
382             Copyright 2020-2022 by Laurent Dami.
383              
384             This library is free software; you can redistribute it and/or modify
385             it under the same terms as Perl itself.