File Coverage

blib/lib/Spreadsheet/ParseXLSX.pm
Criterion Covered Total %
statement 21 23 91.3
branch n/a
condition n/a
subroutine 8 8 100.0
pod n/a
total 29 31 93.5


line stmt bran cond sub pod time code
1             package Spreadsheet::ParseXLSX;
2             our $AUTHORITY = 'cpan:DOY';
3             $Spreadsheet::ParseXLSX::VERSION = '0.26';
4 31     31   402865 use strict;
  31         47  
  31         718  
5 31     31   89 use warnings;
  31         31  
  31         553  
6 31     31   459 use 5.010;
  31         69  
7             # ABSTRACT: parse XLSX files
8              
9 31     31   16880 use Archive::Zip;
  31         1896251  
  31         1122  
10 31     31   16023 use Graphics::ColorUtils 'rgb2hls', 'hls2rgb';
  31         153483  
  31         1923  
11 31     31   161 use Scalar::Util 'openhandle';
  31         34  
  31         1227  
12 31     31   21768 use Spreadsheet::ParseExcel 0.61;
  31         1103845  
  31         797  
13 31     31   56155 use XML::Twig;
  0            
  0            
14              
15             use Spreadsheet::ParseXLSX::Decryptor;
16              
17              
18              
19             sub new {
20             my $class = shift;
21             my (%args) = @_;
22              
23             my $self = bless {}, $class;
24             $self->{Password} = $args{Password} if defined $args{Password};
25              
26             return $self;
27             }
28              
29              
30             sub parse {
31             my $self = shift;
32             my ($file, $formatter) = @_;
33              
34             my $zip = Archive::Zip->new;
35             my $workbook = Spreadsheet::ParseExcel::Workbook->new;
36              
37             if ($self->_check_signature($file)) {
38             my $decrypted_file = Spreadsheet::ParseXLSX::Decryptor->open(
39             $file,
40             $self->{Password}
41             );
42             $file = $decrypted_file if $decrypted_file;
43             }
44              
45             if (openhandle($file)) {
46             bless $file, 'IO::File' if ref($file) eq 'GLOB'; # sigh
47             my $fh = ref($file) eq 'File::Temp'
48             ? IO::File->new("<&=" . fileno($file))
49             : $file;
50             $zip->readFromFileHandle($fh) == Archive::Zip::AZ_OK
51             or die "Can't open filehandle as a zip file";
52             $workbook->{File} = undef;
53             $workbook->{__tempfile} = $file;
54             }
55             elsif (ref($file) eq 'SCALAR') {
56             open my $fh, '+<', $file
57             or die "Can't create filehandle from memory data";
58             $zip->readFromFileHandle($fh) == Archive::Zip::AZ_OK
59             or die "Can't open scalar ref as a zip file";
60             $workbook->{File} = undef;
61             }
62             elsif (!ref($file)) {
63             $zip->read($file) == Archive::Zip::AZ_OK
64             or die "Can't open file '$file' as a zip file";
65             $workbook->{File} = $file;
66             }
67             else {
68             die "Argument to 'new' must be a filename, open filehandle, or scalar ref";
69             }
70              
71             return $self->_parse_workbook($zip, $workbook, $formatter);
72             }
73              
74             sub _check_signature {
75             my $self = shift;
76             my ($file) = @_;
77              
78             my $signature = '';
79             if (openhandle($file)) {
80             bless $file, 'IO::File' if ref($file) eq 'GLOB'; # sigh
81             $file->read($signature, 2);
82             $file->seek(-2, IO::File::SEEK_CUR);
83             }
84             elsif (ref($file) eq 'SCALAR') {
85             $signature = substr($$file, 0, 2);
86             }
87             elsif (!ref($file)) {
88             my $fh = IO::File->new($file, 'r');
89             $fh->read($signature, 2);
90             $fh->close;
91             }
92              
93             return $signature eq "\xd0\xcf";
94             }
95              
96             sub _parse_workbook {
97             my $self = shift;
98             my ($zip, $workbook, $formatter) = @_;
99              
100             my $files = $self->_extract_files($zip);
101              
102             my ($version) = $files->{workbook}->find_nodes('//s:fileVersion');
103             my ($properties) = $files->{workbook}->find_nodes('//s:workbookPr');
104              
105             if ($version) {
106             $workbook->{Version} = $version->att('appName')
107             . ($version->att('lowestEdited')
108             ? ('-' . $version->att('lowestEdited'))
109             : (""));
110             }
111              
112             $workbook->{Flg1904} = $self->_xml_boolean($properties->att('date1904'))
113             if $properties;
114              
115             $workbook->{FmtClass} = $formatter || Spreadsheet::ParseExcel::FmtDefault->new;
116              
117             my $themes = $self->_parse_themes((values %{ $files->{themes} })[0]); # XXX
118              
119             $workbook->{Color} = $themes->{Color};
120              
121             my $styles = $self->_parse_styles($workbook, $files->{styles});
122              
123             $workbook->{Format} = $styles->{Format};
124             $workbook->{FormatStr} = $styles->{FormatStr};
125             $workbook->{Font} = $styles->{Font};
126              
127             if ($files->{strings}) {
128             my %string_parse_data = $self->_parse_shared_strings(
129             $files->{strings},
130             $themes->{Color}
131             );
132             $workbook->{PkgStr} = $string_parse_data{PkgStr};
133             $workbook->{Rich} = $string_parse_data{Rich};
134             }
135              
136             # $workbook->{StandardWidth} = ...;
137              
138             # $workbook->{Author} = ...;
139              
140             # $workbook->{PrintArea} = ...;
141             # $workbook->{PrintTitle} = ...;
142              
143             my @sheets = map {
144             my $idx = $_->att('rels:id');
145             if ($files->{sheets}{$idx}) {
146             my $sheet = Spreadsheet::ParseExcel::Worksheet->new(
147             Name => $_->att('name'),
148             _Book => $workbook,
149             _SheetNo => $idx,
150             );
151             $sheet->{SheetHidden} = 1 if defined $_->att('state') and $_->att('state') eq 'hidden';
152             $self->_parse_sheet($sheet, $files->{sheets}{$idx});
153             ($sheet)
154             } else {
155             ()
156             }
157             } $files->{workbook}->find_nodes('//s:sheets/s:sheet');
158              
159             $workbook->{Worksheet} = \@sheets;
160             $workbook->{SheetCount} = scalar(@sheets);
161              
162             my ($node) = $files->{workbook}->find_nodes('//s:workbookView');
163             my $selected = $node ? $node->att('activeTab') : undef;
164             $workbook->{SelectedSheet} = defined($selected) ? 0+$selected : 0;
165              
166             return $workbook;
167             }
168              
169             sub _parse_sheet {
170             my $self = shift;
171             my ($sheet, $sheet_file) = @_;
172              
173             $sheet->{MinRow} = 0;
174             $sheet->{MinCol} = 0;
175             $sheet->{MaxRow} = -1;
176             $sheet->{MaxCol} = -1;
177             $sheet->{Selection} = [ 0, 0 ];
178              
179             my %merged_cells;
180              
181             my @column_formats;
182             my @column_widths;
183             my @columns_hidden;
184             my @row_heights;
185             my @rows_hidden;
186              
187             my $default_row_height = 15;
188             my $default_column_width = 10;
189              
190             my %cells;
191              
192             my $sheet_xml = $self->_new_twig(
193             twig_roots => {
194             #XXX need a fallback here, the dimension tag is optional
195             's:dimension' => sub {
196             my ($twig, $dimension) = @_;
197              
198             my ($rmin, $cmin, $rmax, $cmax) = $self->_dimensions(
199             $dimension->att('ref')
200             );
201              
202             $sheet->{MinRow} = $rmin;
203             $sheet->{MinCol} = $cmin;
204             $sheet->{MaxRow} = $rmax ? $rmax : -1;
205             $sheet->{MaxCol} = $cmax ? $cmax : -1;
206              
207             $twig->purge;
208             },
209              
210             's:headerFooter' => sub {
211             my ($twig, $hf) = @_;
212              
213             my ($helem, $felem) = map {
214             $hf->first_child("s:$_")
215             } qw(oddHeader oddFooter);
216             $sheet->{Header} = $helem->text
217             if $helem;
218             $sheet->{Footer} = $felem->text
219             if $felem;
220              
221             $twig->purge;
222             },
223              
224             's:pageMargins' => sub {
225             my ($twig, $margin) = @_;
226             map {
227             my $key = "\u${_}Margin";
228             $sheet->{$key} = defined $margin->att($_)
229             ? $margin->att($_) : 0
230             } qw(left right top bottom header footer);
231              
232             $twig->purge;
233             },
234              
235             's:pageSetup' => sub {
236             my ($twig, $setup) = @_;
237             $sheet->{Scale} = defined $setup->att('scale')
238             ? $setup->att('scale')
239             : 100;
240             $sheet->{Landscape} = ($setup->att('orientation') || '') ne 'landscape';
241             $sheet->{PaperSize} = defined $setup->att('paperSize')
242             ? $setup->att('paperSize')
243             : 1;
244             $sheet->{PageStart} = $setup->att('firstPageNumber');
245             $sheet->{UsePage} = $self->_xml_boolean($setup->att('useFirstPageNumber'));
246             $sheet->{HorizontalDPI} = $setup->att('horizontalDpi');
247             $sheet->{VerticalDPI} = $setup->att('verticalDpi');
248              
249             $twig->purge;
250             },
251              
252             's:mergeCells/s:mergeCell' => sub {
253             my ( $twig, $merge_area ) = @_;
254              
255             if (my $ref = $merge_area->att('ref')) {
256             my ($topleft, $bottomright) = $ref =~ /([^:]+):([^:]+)/;
257              
258             my ($toprow, $leftcol) = $self->_cell_to_row_col($topleft);
259             my ($bottomrow, $rightcol) = $self->_cell_to_row_col($bottomright);
260              
261             push @{ $sheet->{MergedArea} }, [
262             $toprow, $leftcol,
263             $bottomrow, $rightcol,
264             ];
265             for my $row ($toprow .. $bottomrow) {
266             for my $col ($leftcol .. $rightcol) {
267             $merged_cells{"$row;$col"} = 1;
268             }
269             }
270             }
271              
272             $twig->purge;
273             },
274              
275             's:sheetFormatPr' => sub {
276             my ( $twig, $format ) = @_;
277              
278             $default_row_height = $format->att('defaultRowHeight')
279             unless defined $default_row_height;
280             $default_column_width = $format->att('baseColWidth')
281             unless defined $default_column_width;
282              
283             $twig->purge;
284             },
285              
286             's:col' => sub {
287             my ( $twig, $col ) = @_;
288              
289             for my $colnum ($col->att('min')..$col->att('max')) {
290             $column_widths[$colnum - 1] = $col->att('width');
291             $column_formats[$colnum - 1] = $col->att('style');
292             $columns_hidden[$colnum - 1] = $self->_xml_boolean($col->att('hidden'));
293             }
294              
295             $twig->purge;
296             },
297              
298             's:row' => sub {
299             my ( $twig, $row ) = @_;
300              
301             $row_heights[ $row->att('r') - 1 ] = $row->att('ht');
302             $rows_hidden[ $row->att('r') - 1 ] = $self->_xml_boolean($row->att('hidden'));
303              
304             $twig->purge;
305             },
306              
307             's:selection' => sub {
308             my ( $twig, $selection ) = @_;
309              
310             if (my $cell = $selection->att('activeCell')) {
311             $sheet->{Selection} = [ $self->_cell_to_row_col($cell) ];
312             }
313             elsif (my $range = $selection->att('sqref')) {
314             my ($topleft, $bottomright) = $range =~ /([^:]+):([^:]+)/;
315             $sheet->{Selection} = [
316             $self->_cell_to_row_col($topleft),
317             $self->_cell_to_row_col($bottomright),
318             ];
319             }
320              
321             $twig->purge;
322             },
323              
324             's:sheetPr/s:tabColor' => sub {
325             my ( $twig, $tab_color ) = @_;
326              
327             $sheet->{TabColor} = $self->_color($sheet->{_Book}{Color}, $tab_color);
328              
329             $twig->purge;
330             },
331              
332             's:sheetData/s:row' => sub {
333             my ( $twig, $row_elt ) = @_;
334              
335             for my $cell ( $row_elt->children('s:c') ){
336             my ($row, $col) = $self->_cell_to_row_col($cell->att('r'));
337             $sheet->{MaxRow} = $row
338             if $sheet->{MaxRow} < $row;
339             $sheet->{MaxCol} = $col
340             if $sheet->{MaxCol} < $col;
341             my $type = $cell->att('t') || 'n';
342             my $val_xml;
343             if ($type ne 'inlineStr') {
344             $val_xml = $cell->first_child('s:v');
345             }
346             elsif (defined $cell->first_child('s:is')) {
347             $val_xml = ($cell->find_nodes('.//s:t'))[0];
348             }
349             my $val = $val_xml ? $val_xml->text : undef;
350              
351             my $long_type;
352             my $Rich;
353             if (!defined($val)) {
354             $long_type = 'Text';
355             $val = '';
356             }
357             elsif ($type eq 's') {
358             $long_type = 'Text';
359             $Rich = $sheet->{_Book}{Rich}->{$val};
360             $val = $sheet->{_Book}{PkgStr}[$val];
361             }
362             elsif ($type eq 'n') {
363             $long_type = 'Numeric';
364             $val = defined($val) ? 0+$val : undef;
365             }
366             elsif ($type eq 'd') {
367             $long_type = 'Date';
368             }
369             elsif ($type eq 'b') {
370             $long_type = 'Text';
371             $val = $val ? "TRUE" : "FALSE";
372             }
373             elsif ($type eq 'e') {
374             $long_type = 'Text';
375             }
376             elsif ($type eq 'str' || $type eq 'inlineStr') {
377             $long_type = 'Text';
378             }
379             else {
380             die "unimplemented type $type"; # XXX
381             }
382              
383             my $format_idx = $cell->att('s') || 0;
384             my $format = $sheet->{_Book}{Format}[$format_idx];
385             die "unknown format $format_idx" unless $format;
386              
387             # see the list of built-in formats below in _parse_styles
388             # XXX probably should figure this out from the actual format string,
389             # but that's not entirely trivial
390             if (grep { $format->{FmtIdx} == $_ } 14..22, 45..47) {
391             $long_type = 'Date';
392             }
393              
394             my $formula = $cell->first_child('s:f');
395             my $cell = Spreadsheet::ParseExcel::Cell->new(
396             Val => $val,
397             Type => $long_type,
398             Merged => undef, # fix up later
399             Format => $format,
400             FormatNo => $format_idx,
401             ($formula
402             ? (Formula => $formula->text)
403             : ()),
404             Rich => $Rich,
405             );
406             $cell->{_Value} = $sheet->{_Book}{FmtClass}->ValFmt(
407             $cell, $sheet->{_Book}
408             );
409             $cells{"$row;$col"} = $cell;
410             $sheet->{Cells}[$row][$col] = $cell;
411             }
412              
413             $twig->purge;
414             },
415             }
416             );
417              
418             $sheet_xml->parse( $sheet_file );
419              
420             for my $key (keys %merged_cells) {
421             $cells{$key}{Merged} = 1 if $cells{$key};
422             }
423              
424             if ( ! $sheet->{Cells} ){
425             $sheet->{MaxRow} = $sheet->{MaxCol} = -1;
426             }
427              
428             $sheet->{DefRowHeight} = 0+$default_row_height;
429             $sheet->{DefColWidth} = 0+$default_column_width;
430             $sheet->{RowHeight} = [
431             map { defined $_ ? 0+$_ : 0+$default_row_height } @row_heights
432             ];
433             $sheet->{RowHidden} = \@rows_hidden;
434             $sheet->{ColWidth} = [
435             map { defined $_ ? 0+$_ : 0+$default_column_width } @column_widths
436             ];
437             $sheet->{ColFmtNo} = \@column_formats;
438             $sheet->{ColHidden} = \@columns_hidden;
439              
440             }
441              
442             sub _get_text_and_rich_font_by_cell {
443             my $self = shift;
444             my ($si, $theme_colors) = @_;
445              
446             # XXX
447             my %default_font_opts = (
448             Height => 12,
449             Color => '#000000',
450             Name => '',
451             Bold => 0,
452             Italic => 0,
453             Underline => 0,
454             UnderlineStyle => 0,
455             Strikeout => 0,
456             Super => 0,
457             );
458              
459             my $string_text = '';
460             my @rich_font_by_cell;
461             my @nodes_r = $si->find_nodes('.//s:r');
462             if (@nodes_r > 0) {
463             for my $chunk (map { $_->children } @nodes_r) {
464             my $string_length = length($string_text);
465             if ($chunk->name eq 's:t') {
466             if (!@rich_font_by_cell) {
467             push @rich_font_by_cell, [
468             $string_length,
469             Spreadsheet::ParseExcel::Font->new(%default_font_opts)
470             ];
471             }
472             $string_text .= $chunk->text;
473             }
474             elsif ($chunk->name eq 's:rPr') {
475             my %format_text = %default_font_opts;
476             for my $node_format ($chunk->children) {
477             if ($node_format->name eq 's:sz') {
478             $format_text{Height} = $node_format->att('val');
479             }
480             elsif ($node_format->name eq 's:color') {
481             $format_text{Color} = $self->_color(
482             $theme_colors,
483             $node_format
484             );
485             }
486             elsif ($node_format->name eq 's:rFont') {
487             $format_text{Name} = $node_format->att('val');
488             }
489             elsif ($node_format->name eq 's:b') {
490             $format_text{Bold} = 1;
491             }
492             elsif ($node_format->name eq 's:i') {
493             $format_text{Italic} = 1;
494             }
495             elsif ($node_format->name eq 's:u') {
496             $format_text{Underline} = 1;
497             if (defined $node_format->att('val')) {
498             $format_text{UnderlineStyle} = 2;
499             } else {
500             $format_text{UnderlineStyle} = 1;
501             }
502             }
503             elsif ($node_format->name eq 's:strike') {
504             $format_text{Strikeout} = 1;
505             }
506             elsif ($node_format->name eq 's:vertAlign') {
507             if ($node_format->att('val') eq 'superscript') {
508             $format_text{Super} = 1;
509             }
510             elsif ($node_format->att('val') eq 'subscript') {
511             $format_text{Super} = 2;
512             }
513             }
514             }
515             push @rich_font_by_cell, [
516             $string_length,
517             Spreadsheet::ParseExcel::Font->new(%format_text)
518             ];
519             }
520             }
521             }
522             else {
523             $string_text = join '', map { $_->text } $si->find_nodes('.//s:t');
524             }
525              
526             return (
527             String => $string_text,
528             Rich => \@rich_font_by_cell,
529             );
530             }
531              
532             sub _parse_shared_strings {
533             my $self = shift;
534             my ($strings, $theme_colors) = @_;
535              
536             my $PkgStr = [];
537              
538             my %richfonts;
539             if ($strings) {
540             my $xml = $self->_new_twig(
541             twig_handlers => {
542             's:si' => sub {
543             my ( $twig, $si ) = @_;
544              
545             my %text_rich = $self->_get_text_and_rich_font_by_cell(
546             $si,
547             $theme_colors
548             );
549             $richfonts{scalar @$PkgStr} = $text_rich{Rich};
550             push @$PkgStr, $text_rich{String};
551             $twig->purge;
552             },
553             }
554             );
555             $xml->parse( $strings );
556             }
557             return (
558             Rich => \%richfonts,
559             PkgStr => $PkgStr,
560             );
561             }
562              
563             sub _parse_themes {
564             my $self = shift;
565             my ($themes) = @_;
566              
567             return {} unless $themes;
568              
569             my @color = map {
570             $_->name eq 'drawmain:sysClr' ? $_->att('lastClr') : $_->att('val')
571             } $themes->find_nodes('//drawmain:clrScheme/*/*');
572              
573             # this shouldn't be necessary, but the documentation is wrong here
574             # see http://stackoverflow.com/questions/2760976/theme-confusion-in-spreadsheetml
575             ($color[0], $color[1]) = ($color[1], $color[0]);
576             ($color[2], $color[3]) = ($color[3], $color[2]);
577              
578             return {
579             Color => \@color,
580             }
581             }
582              
583             sub _parse_styles {
584             my $self = shift;
585             my ($workbook, $styles) = @_;
586              
587             # these defaults are from
588             # http://social.msdn.microsoft.com/Forums/en-US/oxmlsdk/thread/e27aaf16-b900-4654-8210-83c5774a179c
589             my %default_format_str = (
590             0 => 'GENERAL',
591             1 => '0',
592             2 => '0.00',
593             3 => '#,##0',
594             4 => '#,##0.00',
595             5 => '$#,##0_);($#,##0)',
596             6 => '$#,##0_);[Red]($#,##0)',
597             7 => '$#,##0.00_);($#,##0.00)',
598             8 => '$#,##0.00_);[Red]($#,##0.00)',
599             9 => '0%',
600             10 => '0.00%',
601             11 => '0.00E+00',
602             12 => '# ?/?',
603             13 => '# ??/??',
604             14 => 'm/d/yyyy',
605             15 => 'd-mmm-yy',
606             16 => 'd-mmm',
607             17 => 'mmm-yy',
608             18 => 'h:mm AM/PM',
609             19 => 'h:mm:ss AM/PM',
610             20 => 'h:mm',
611             21 => 'h:mm:ss',
612             22 => 'm/d/yyyy h:mm',
613             37 => '#,##0_);(#,##0)',
614             38 => '#,##0_);[Red](#,##0)',
615             39 => '#,##0.00_);(#,##0.00)',
616             40 => '#,##0.00_);[Red](#,##0.00)',
617             45 => 'mm:ss',
618             46 => '[h]:mm:ss',
619             47 => 'mm:ss.0',
620             48 => '##0.0E+0',
621             49 => '@',
622             );
623              
624             my %default_format_opts = (
625             IgnoreFont => 1,
626             IgnoreFill => 1,
627             IgnoreBorder => 1,
628             IgnoreAlignment => 1,
629             IgnoreNumberFormat => 1,
630             IgnoreProtection => 1,
631             FontNo => 0,
632             FmtIdx => 0,
633             Lock => 1,
634             Hidden => 0,
635             AlignH => 0,
636             Wrap => 0,
637             AlignV => 2,
638             Rotate => 0,
639             Indent => 0,
640             Shrink => 0,
641             BdrStyle => [0, 0, 0, 0],
642             BdrColor => [undef, undef, undef, undef],
643             BdrDiag => [0, 0, undef],
644             Fill => [0, undef, undef],
645             );
646              
647             if (!$styles) {
648             # XXX i guess?
649             my $font = Spreadsheet::ParseExcel::Font->new(
650             Height => 12,
651             Color => '#000000',
652             Name => '',
653             );
654             my $format = Spreadsheet::ParseExcel::Format->new(
655             %default_format_opts,
656             Font => $font,
657             );
658              
659             return {
660             FormatStr => \%default_format_str,
661             Font => [ $font ],
662             Format => [ $format ],
663             };
664             }
665              
666             my %halign = (
667             center => 2,
668             centerContinuous => 6,
669             distributed => 7,
670             fill => 4,
671             general => 0,
672             justify => 5,
673             left => 1,
674             right => 3,
675             );
676              
677             my %valign = (
678             bottom => 2,
679             center => 1,
680             distributed => 4,
681             justify => 3,
682             top => 0,
683             );
684              
685             my %border = (
686             dashDot => 9,
687             dashDotDot => 11,
688             dashed => 3,
689             dotted => 4,
690             double => 6,
691             hair => 7,
692             medium => 2,
693             mediumDashDot => 10,
694             mediumDashDotDot => 12,
695             mediumDashed => 8,
696             none => 0,
697             slantDashDot => 13,
698             thick => 5,
699             thin => 1,
700             );
701              
702             my %fill = (
703             darkDown => 7,
704             darkGray => 3,
705             darkGrid => 9,
706             darkHorizontal => 5,
707             darkTrellis => 10,
708             darkUp => 8,
709             darkVertical => 6,
710             gray0625 => 18,
711             gray125 => 17,
712             lightDown => 13,
713             lightGray => 4,
714             lightGrid => 15,
715             lightHorizontal => 11,
716             lightTrellis => 16,
717             lightUp => 14,
718             lightVertical => 12,
719             mediumGray => 2,
720             none => 0,
721             solid => 1,
722             );
723              
724             my @fills = map {
725             my $pattern_type = $_->att('patternType');
726             [
727             ($pattern_type ? $fill{$pattern_type} : 0),
728             $self->_color($workbook->{Color}, $_->first_child('s:fgColor'), 1),
729             $self->_color($workbook->{Color}, $_->first_child('s:bgColor'), 1),
730             ]
731             } $styles->find_nodes('//s:fills/s:fill/s:patternFill');
732              
733             my @borders = map {
734             my $border = $_;
735             my ($ddiag, $udiag) = map {
736             $self->_xml_boolean($border->att($_))
737             } qw(diagonalDown diagonalUp);
738             my %borderstyles = map {
739             my $e = $border->first_child("s:$_");
740             $_ => ($e ? $e->att('style') || 'none' : 'none')
741             } qw(left right top bottom diagonal);
742             my %bordercolors = map {
743             my $e = $border->first_child("s:$_");
744             $_ => ($e ? $e->first_child('s:color') : undef)
745             } qw(left right top bottom diagonal);
746             # XXX specs say "begin" and "end" rather than "left" and "right",
747             # but... that's not what seems to be in the file itself (sigh)
748             {
749             colors => [
750             map {
751             $self->_color($workbook->{Color}, $bordercolors{$_})
752             } qw(left right top bottom)
753             ],
754             styles => [
755             map {
756             $border{$borderstyles{$_}}
757             } qw(left right top bottom)
758             ],
759             diagonal => [
760             ( $ddiag && $udiag ? 3
761             : $ddiag && !$udiag ? 2
762             : !$ddiag && $udiag ? 1
763             : 0),
764             $border{$borderstyles{diagonal}},
765             $self->_color($workbook->{Color}, $bordercolors{diagonal}),
766             ],
767             }
768             } $styles->find_nodes('//s:borders/s:border');
769              
770             my %format_str = (
771             %default_format_str,
772             (map {
773             $_->att('numFmtId') => $_->att('formatCode')
774             } $styles->find_nodes('//s:numFmts/s:numFmt')),
775             );
776              
777             my @font = map {
778             my $vert = $_->first_child('s:vertAlign');
779             my $under = $_->first_child('s:u');
780             my $heightelem = $_->first_child('s:sz');
781             # XXX i guess 12 is okay?
782             my $height = 0+($heightelem ? $heightelem->att('val') : 12);
783             my $nameelem = $_->first_child('s:name');
784             my $name = $nameelem ? $nameelem->att('val') : '';
785             Spreadsheet::ParseExcel::Font->new(
786             Height => $height,
787             # Attr => $iAttr,
788             # XXX not sure if there's a better way to keep the indexing stuff
789             # intact rather than just going straight to #xxxxxx
790             # XXX also not sure what it means for the color tag to be missing,
791             # just assuming black for now
792             Color => ($_->first_child('s:color')
793             ? $self->_color(
794             $workbook->{Color},
795             $_->first_child('s:color')
796             )
797             : '#000000'
798             ),
799             Super => ($vert
800             ? ($vert->att('val') eq 'superscript' ? 1
801             : $vert->att('val') eq 'subscript' ? 2
802             : 0)
803             : 0
804             ),
805             # XXX not sure what the single accounting and double accounting
806             # underline styles map to in xlsx. also need to map the new
807             # underline styles
808             UnderlineStyle => ($under
809             # XXX sometimes style xml files can contain just with no
810             # val attribute. i think this means single underline, but not
811             # sure
812             ? (!$under->att('val') ? 1
813             : $under->att('val') eq 'single' ? 1
814             : $under->att('val') eq 'double' ? 2
815             : 0)
816             : 0
817             ),
818             Name => $name,
819              
820             Bold => $_->has_child('s:b') ? 1 : 0,
821             Italic => $_->has_child('s:i') ? 1 : 0,
822             Underline => $_->has_child('s:u') ? 1 : 0,
823             Strikeout => $_->has_child('s:strike') ? 1 : 0,
824             )
825             } $styles->find_nodes('//s:fonts/s:font');
826              
827             my @format = map {
828             my $xml_fmt = $_;
829             my $alignment = $xml_fmt->first_child('s:alignment');
830             my $protection = $xml_fmt->first_child('s:protection');
831             my %ignore = map {
832             ("Ignore$_" => !$self->_xml_boolean($xml_fmt->att("apply$_")))
833             } qw(Font Fill Border Alignment NumberFormat Protection);
834             my %opts = (
835             %default_format_opts,
836             %ignore,
837             );
838              
839             $opts{FmtIdx} = 0+($xml_fmt->att('numFmtId')||0);
840             $opts{FontNo} = 0+($xml_fmt->att('fontId')||0);
841             $opts{Font} = $font[$opts{FontNo}];
842             $opts{Fill} = $fills[$xml_fmt->att('fillId')||0];
843             $opts{BdrStyle} = $borders[$xml_fmt->att('borderId')||0]{styles};
844             $opts{BdrColor} = $borders[$xml_fmt->att('borderId')||0]{colors};
845             $opts{BdrDiag} = $borders[$xml_fmt->att('borderId')||0]{diagonal};
846              
847             if ($alignment) {
848             $opts{AlignH} = $halign{$alignment->att('horizontal') || 'general'};
849             $opts{Wrap} = $self->_xml_boolean($alignment->att('wrapText'));
850             $opts{AlignV} = $valign{$alignment->att('vertical') || 'bottom'};
851             $opts{Rotate} = $alignment->att('textRotation');
852             $opts{Indent} = $alignment->att('indent');
853             $opts{Shrink} = $self->_xml_boolean($alignment->att('shrinkToFit'));
854             # JustLast => $iJustL,
855             }
856              
857             if ($protection) {
858             $opts{Lock} = defined $protection->att('locked')
859             ? $self->_xml_boolean($protection->att('locked'))
860             : 1;
861             $opts{Hidden} = $self->_xml_boolean($protection->att('hidden'));
862             }
863              
864             # Style => $iStyle,
865             # Key123 => $i123,
866             # Merge => $iMerge,
867             # ReadDir => $iReadDir,
868             Spreadsheet::ParseExcel::Format->new(%opts)
869             } $styles->find_nodes('//s:cellXfs/s:xf');
870              
871             return {
872             FormatStr => \%format_str,
873             Font => \@font,
874             Format => \@format,
875             }
876             }
877              
878             sub _extract_files {
879             my $self = shift;
880             my ($zip) = @_;
881              
882             my $type_base =
883             'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
884              
885             my $rels = $self->_parse_xml(
886             $zip,
887             $self->_rels_for(''),
888             );
889             my $wb_name = ($rels->find_nodes(
890             qq
891             ))[0]->att('Target');
892             $wb_name =~ s{^/}{};
893             my $wb_xml = $self->_parse_xml($zip, $wb_name);
894              
895             my $path_base = $self->_base_path_for($wb_name);
896             my $wb_rels = $self->_parse_xml(
897             $zip,
898             $self->_rels_for($wb_name),
899             );
900              
901             my $get_path = sub {
902             my ($p) = @_;
903              
904             return $p =~ s{^/}{}
905             ? $p
906             : $path_base . $p;
907             };
908              
909             my ($strings_xml) = map {
910             $self->_zip_file_member($zip, $get_path->($_->att('Target')))
911             } $wb_rels->find_nodes(qq);
912              
913             my ($styles_xml) = map {
914             $self->_parse_xml(
915             $zip,
916             $get_path->($_->att('Target'))
917             )
918             } $wb_rels->find_nodes(qq);
919              
920             my %worksheet_xml = map {
921             ($_->att('Id') => $self->_zip_file_member($zip, $get_path->($_->att('Target'))))
922             } $wb_rels->find_nodes(qq);
923              
924             my %themes_xml = map {
925             $_->att('Id') => $self->_parse_xml($zip, $get_path->($_->att('Target')))
926             } $wb_rels->find_nodes(qq);
927              
928             return {
929             workbook => $wb_xml,
930             sheets => \%worksheet_xml,
931             themes => \%themes_xml,
932             ($styles_xml
933             ? (styles => $styles_xml)
934             : ()),
935             ($strings_xml
936             ? (strings => $strings_xml)
937             : ()),
938             };
939             }
940              
941             sub _parse_xml {
942             my $self = shift;
943             my ($zip, $subfile, $map_xmlns) = @_;
944              
945             my $xml = $self->_new_twig;
946             $xml->parse($self->_zip_file_member($zip, $subfile));
947              
948             return $xml;
949             }
950              
951             sub _zip_file_member {
952             my $self = shift;
953             my ($zip, $name) = @_;
954              
955             my @members = $zip->membersMatching(qr/^$name$/i);
956             die "no subfile named $name" unless @members;
957              
958             return scalar $members[0]->contents;
959             }
960              
961             sub _rels_for {
962             my $self = shift;
963             my ($file) = @_;
964              
965             my @path = split '/', $file;
966             my $name = pop @path;
967             $name = '' unless defined $name;
968             push @path, '_rels';
969             push @path, "$name.rels";
970              
971             return join '/', @path;
972             }
973              
974             sub _base_path_for {
975             my $self = shift;
976             my ($file) = @_;
977              
978             my @path = split '/', $file;
979             pop @path;
980              
981             return join('/', @path) . '/';
982             }
983              
984             sub _dimensions {
985             my $self = shift;
986             my ($dim) = @_;
987              
988             my ($topleft, $bottomright) = split ':', $dim;
989             $bottomright = $topleft unless defined $bottomright;
990              
991             my ($rmin, $cmin) = $self->_cell_to_row_col($topleft);
992             my ($rmax, $cmax) = $self->_cell_to_row_col($bottomright);
993              
994             return ($rmin, $cmin, $rmax, $cmax);
995             }
996              
997             sub _cell_to_row_col {
998             my $self = shift;
999             my ($cell) = @_;
1000              
1001             my ($col, $row) = $cell =~ /([A-Z]+)([0-9]+)/;
1002              
1003             my $ncol = 0;
1004             for my $char (split //, $col) {
1005             $ncol *= 26;
1006             $ncol += ord($char) - ord('A') + 1;
1007             }
1008             $ncol = $ncol - 1;
1009              
1010             my $nrow = $row - 1;
1011              
1012             return ($nrow, $ncol);
1013             }
1014              
1015             sub _xml_boolean {
1016             my $self = shift;
1017             my ($bool) = @_;
1018             return defined($bool) && ($bool eq 'true' || $bool eq '1');
1019             }
1020              
1021             sub _color {
1022             my $self = shift;
1023             my ($colors, $color_node, $fill) = @_;
1024              
1025             my $color;
1026             if ($color_node && !$self->_xml_boolean($color_node->att('auto'))) {
1027             if (defined $color_node->att('indexed')) {
1028             # see https://rt.cpan.org/Public/Bug/Display.html?id=93065
1029             if ($fill && $color_node->att('indexed') == 64) {
1030             return '#FFFFFF';
1031             }
1032             else {
1033             $color = '#' . Spreadsheet::ParseExcel->ColorIdxToRGB(
1034             $color_node->att('indexed')
1035             );
1036             }
1037             }
1038             elsif (defined $color_node->att('rgb')) {
1039             $color = '#' . substr($color_node->att('rgb'), 2, 6);
1040             }
1041             elsif (defined $color_node->att('theme')) {
1042             my $theme = $colors->[$color_node->att('theme')];
1043             if (defined $theme) {
1044             $color = "#$theme";
1045             }
1046             else {
1047             return undef;
1048             }
1049             }
1050              
1051             $color = $self->_apply_tint($color, $color_node->att('tint'))
1052             if $color_node->att('tint');
1053             }
1054              
1055             return $color;
1056             }
1057              
1058             sub _apply_tint {
1059             my $self = shift;
1060             my ($color, $tint) = @_;
1061              
1062             my ($r, $g, $b) = map { oct("0x$_") } $color =~ /#(..)(..)(..)/;
1063             my ($h, $l, $s) = rgb2hls($r, $g, $b);
1064              
1065             if ($tint < 0) {
1066             $l = $l * (1.0 + $tint);
1067             }
1068             else {
1069             $l = $l * (1.0 - $tint) + (1.0 - 1.0 * (1.0 - $tint));
1070             }
1071              
1072             return scalar hls2rgb($h, $l, $s);
1073             }
1074              
1075             sub _new_twig {
1076             my $self = shift;
1077             my %opts = @_;
1078              
1079             return XML::Twig->new(
1080             map_xmlns => {
1081             'http://schemas.openxmlformats.org/spreadsheetml/2006/main' => 's',
1082             'http://schemas.openxmlformats.org/package/2006/relationships' => 'packagerels',
1083             'http://schemas.openxmlformats.org/officeDocument/2006/relationships' => 'rels',
1084             'http://schemas.openxmlformats.org/drawingml/2006/main' => 'drawmain',
1085             },
1086             keep_original_prefix => 1,
1087             %opts,
1088             );
1089             }
1090              
1091              
1092             1;
1093              
1094             __END__