File Coverage

blib/lib/Excel/ValueWriter/XLSX.pm
Criterion Covered Total %
statement 211 223 94.6
branch 39 52 75.0
condition 19 29 65.5
subroutine 37 38 97.3
pod 5 21 23.8
total 311 363 85.6


line stmt bran cond sub pod time code
1 4     4   288336 use 5.014;
  4         47  
2             package Excel::ValueWriter::XLSX;
3 4     4   23 use strict;
  4         9  
  4         82  
4 4     4   20 use warnings;
  4         15  
  4         161  
5 4     4   25 use utf8;
  4         6  
  4         28  
6 4     4   3188 use Archive::Zip qw/AZ_OK COMPRESSION_LEVEL_DEFAULT/;
  4         382823  
  4         276  
7 4     4   40 use Scalar::Util qw/looks_like_number/;
  4         11  
  4         227  
8 4     4   27 use List::Util qw/none/;
  4         9  
  4         457  
9 4     4   2215 use Params::Validate qw/validate_with SCALAR SCALARREF UNDEF/;
  4         37800  
  4         314  
10 4     4   2056 use POSIX qw/strftime/;
  4         26403  
  4         34  
11 4     4   7700 use Date::Calc qw/Delta_Days/;
  4         23538  
  4         332  
12 4     4   31 use Carp qw/croak/;
  4         9  
  4         175  
13 4     4   23 use Encode qw/encode_utf8/;
  4         7  
  4         3515  
14              
15             our $VERSION = '1.03';
16              
17             #======================================================================
18             # GLOBALS
19             #======================================================================
20              
21             my $DATE_STYLE = 1; # 0-based index into the format for dates ..
22             # .. defined in the styles() method
23              
24             my $SHEET_NAME = qr(^[^\\/?*\[\]]{1,31}$); # valid sheet names: <32 chars, no chars \/?*[]
25             my $TABLE_NAME = qr(^\w{3,}$); # valid table names: >= 3 chars, no spaces
26              
27              
28             # specification in Params::Validate format for checking parameters to the new() method
29             my %params_spec = (
30              
31             # date_regex : for identifying dates in data cells. Should capture into $+{d}, $+{m} and $+{y}.
32             date_regex => {type => SCALARREF|UNDEF, optional => 1, default =>
33             qr[^(?: (?\d\d?) \. (?\d\d?) \. (?\d\d\d\d) # dd.mm.yyyy
34             | (?\d\d\d\d) - (?\d\d?) - (?\d\d?) # yyyy-mm-dd
35             | (?\d\d?) / (?\d\d?) / (?\d\d\d\d)) # mm/dd/yyyy
36             $]x},
37              
38             # bool_regex : for identifying booleans in data cells. If true, should capture into $1
39             bool_regex => {type => SCALARREF|UNDEF, optional => 1, default => qr[^(?:(TRUE)|FALSE)$]},
40              
41             compression_level => {type => SCALAR, regex => qr/^\d$/, optional => 1, default => COMPRESSION_LEVEL_DEFAULT},
42              
43             );
44              
45              
46             my %entity = ( '<' => '<', '>' => '>', '&' => '&' );
47             my $entity_regex = do {my $chars = join "", keys %entity; qr/[$chars]/};
48              
49              
50             #======================================================================
51             # CONSTRUCTOR
52             #======================================================================
53              
54             sub new {
55 5     5 1 15888 my $class = shift;
56              
57             # check parameters and create $self
58 5         141 my $self = validate_with( params => \@_,
59             spec => \%params_spec,
60             allow_extra => 0,
61             );
62              
63             # initial values for internal data structures
64 5         47 $self->{sheets} = []; # array of sheet names
65 5         15 $self->{tables} = []; # array of table names
66 5         13 $self->{shared_string} = {}; # ($string => $string_index)
67 5         14 $self->{n_strings_in_workbook} = 0; # total nb of strings (including duplicates)
68 5         14 $self->{last_string_id} = 0; # index for the next shared string
69 5         12 $self->{defined_names} = {}; # ($name => [$formula, $comment])
70              
71             # immediately open a Zip archive
72 5         31 $self->{zip} = Archive::Zip->new;
73              
74             # return the constructed object
75 5         264 bless $self, $class;
76             }
77              
78              
79             #======================================================================
80             # GATHERING DATA
81             #======================================================================
82              
83              
84             sub add_sheet {
85             # 3rd parameter ($headers) may be omitted -- so we insert an undef if necessary
86 15 100   15 1 1024 splice @_, 3, 0, undef if @_ < 5;
87              
88             # now we can parse the parameters
89 15         48 my ($self, $sheet_name, $table_name, $headers, $code_or_array) = @_;
90              
91             # check if the given sheet name is valid
92 15 50       158 $sheet_name =~ $SHEET_NAME
93             or croak "'$sheet_name' is not a valid sheet name";
94 15 50   30   83 none {$sheet_name eq $_} @{$self->{sheets}}
  30         58  
  15         88  
95             or croak "this workbook already has a sheet named '$sheet_name'";
96              
97             # local copies for convenience
98 15         62 my $date_regex = $self->{date_regex};
99 15         30 my $bool_regex = $self->{bool_regex};
100              
101             # iterator for generating rows; either received as argument or built as a closure upon an array
102             my $next_row
103             = ref $code_or_array eq 'CODE' ? $code_or_array
104             : ref $code_or_array ne 'ARRAY' ? croak 'add_sheet() : missing or invalid $rows argument'
105 15 100   46   62 : do {my $i = 0; sub { $i < @$code_or_array ? $code_or_array->[$i++] : undef}};
  13 50       26  
  13 100       52  
  46         162  
106              
107             # if $headers were not given explicitly, the first row will do
108 15   100     55 $headers //= $next_row->();
109              
110             # array of column references in A1 Excel notation
111 15         38 my @col_letters = ('A'); # this array will be expanded on demand in the loop below
112              
113             # register the sheet name
114 15         23 push @{$self->{sheets}}, $sheet_name;
  15         36  
115              
116             # start building XML for the sheet
117 15         41 my @xml = (
118             q{},
119             q{
120             q{ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">},
121             q{},
122             );
123              
124             # loop over rows and columns
125 15         25 my $row_num = 0;
126             ROW:
127 15         42 for (my $row = $headers; $row; $row = $next_row->()) {
128 1040         291262 $row_num++;
129 1040 50       2471 my $last_col = @$row or next ROW;
130 1040         1624 my @cells;
131              
132             COLUMN:
133 1040         2875 foreach my $col (0 .. $last_col-1) {
134              
135             # if this column letter is not known yet, compute it using Perl's increment op on strings
136             my $col_letter = $col_letters[$col]
137 300704   66     606404 //= do {my $prev_letter = $col_letters[$col-1]; ++$prev_letter};
  615         1013  
  615         1768  
138              
139             # get the value; if the cell is empty, no need to write it into the XML
140 300704         427254 my $val = $row->[$col];
141 300704 100 66     1132619 defined $val and length $val or next COLUMN;
142              
143             # choose XML attributes and inner value
144             (my $tag, my $attrs, $val)
145             = looks_like_number $val ? (v => "" , $val )
146 4 100 66 4   1903 : $date_regex && $val =~ $date_regex ? (v => qq{ s="$DATE_STYLE"}, n_days($+{y}, $+{m}, $+{d}) )
  4 100 100     1501  
  4 100       12266  
  300702 100       736553  
    100          
147             : $bool_regex && $val =~ $bool_regex ? (v => qq{ t="b"} , $1 ? 1 : 0 )
148             : $val =~ /^=/ ? (f => "", escape_formula($val) )
149             : (v => qq{ t="s"} , $self->add_shared_string($val));
150              
151             # add the new XML cell
152 300702         1229373 my $cell = sprintf qq{<%s>%s}, $col_letter, $row_num, $attrs, $tag, $val, $tag;
153 300702         634036 push @cells, $cell;
154             }
155              
156             # generate the row XML and add it to the sheet
157 1040         42929 my $row_xml = join "", qq{}, @cells, qq{};
158 1040         21292 push @xml, $row_xml;
159             }
160              
161             # close sheet data
162 15         49 push @xml, q{};
163              
164             # if required, add the table corresponding to this sheet into the zip archive, and refer to it in XML
165 15         27 my @table_rels;
166 15 100 100     61 if ($table_name && $row_num) {
167 9         70 my $table_id = $self->add_table($table_name, $col_letters[-1], $row_num, @$headers);
168 9         31 push @table_rels, $table_id;
169 9         28 push @xml, q{};
170             }
171              
172             # close the worksheet xml
173 15         31 push @xml, q{};
174              
175             # insert the sheet and its rels into the zip archive
176 15         55 my $sheet_id = $self->n_sheets;
177 15         51 my $sheet_file = "sheet$sheet_id.xml";
178             $self->{zip}->addString(encode_utf8(join("", @xml)),
179             "xl/worksheets/$sheet_file",
180 15         59939 $self->{compression_level});
181             $self->{zip}->addString($self->worksheet_rels(@table_rels),
182             "xl/worksheets/_rels/$sheet_file.rels",
183 15         19773 $self->{compression_level});
184              
185 15         3783 return $sheet_id;
186             }
187              
188              
189              
190             sub add_sheets_from_database {
191 0     0 1 0 my ($self, $dbh, $sheet_prefix, @table_names) = @_;
192              
193             # in absence of table names, get them from the database metadata
194 0 0       0 if (!@table_names) {
195 0         0 my $tables = $dbh->table_info(undef, undef, undef, 'TABLE')->fetchall_arrayref({});
196 0         0 @table_names = map {$_->{TABLE_NAME}} @$tables;
  0         0  
197             }
198              
199 0   0     0 $sheet_prefix //= "S.";
200              
201 0         0 foreach my $table (@table_names) {
202 0         0 my $sth = $dbh->prepare("select * from $table");
203 0         0 $sth->execute;
204 0         0 my $headers = $sth->{NAME};
205 0         0 my $rows = $sth->fetchall_arrayref;
206 0         0 $self->add_sheet("$sheet_prefix$table", $table, $headers, $rows);
207             }
208             }
209              
210              
211              
212             sub add_shared_string {
213 654     654 0 1406 my ($self, $string) = @_;
214              
215             # single quote before an initial equal sign is ignored (escaping the '=' like in Excel)
216 654         1039 $string =~ s/^'=/=/;
217              
218             # keep a global count of how many strings are in the workbook
219 654         960 $self->{n_strings_in_workbook}++;
220              
221             # if that string was already stored, return its id, otherwise create a new id
222 654   100     2988 $self->{shared_strings}{$string} //= $self->{last_string_id}++;
223             }
224              
225              
226              
227             sub add_table {
228 9     9 0 137 my ($self, $table_name, $last_col, $last_row, @col_names) = @_;
229              
230             # check if the given table name is valid
231 9 50       95 $table_name =~ $TABLE_NAME
232             or croak "'$table_name' is not a valid table name";
233 9 50   6   56 none {$table_name eq $_} @{$self->{tables}}
  6         22  
  9         52  
234             or croak "this workbook already has a table named '$table_name'";
235              
236             # register this table
237 9         42 push @{$self->{tables}}, $table_name;
  9         28  
238 9         54 my $table_id = $self->n_tables;
239              
240             # build column headers from first data row
241 9         59 unshift @col_names, undef; # so that the first index is at 1, not 0
242 9         68 my @columns = map {qq{}} 1 .. $#col_names;
  618         1623  
243              
244             # Excel range of this table
245 9         78 my $ref = "A1:$last_col$last_row";
246              
247             # assemble XML for the table
248 9         176 my @xml = (
249             qq{},
250             qq{
251             qq{ id="$table_id" displayName="$table_name" ref="$ref" totalsRowShown="0">},
252             qq{},
253             qq{},
254             @columns,
255             qq{},
256             qq{},
257             qq{
},
258             );
259              
260             # insert into the zip archive
261             $self->{zip}->addString(encode_utf8(join "", @xml),
262             "xl/tables/table$table_id.xml",
263 9         342 $self->{compression_level});
264              
265 9         3302 return $table_id;
266             }
267              
268              
269             sub add_defined_name {
270 2     2 1 17 my ($self, $name, $formula, $comment) = @_;
271              
272 2 50 33     12 $name && $formula or croak 'add_defined_name($name, $formula): empty argument';
273 2 50       9 not exists $self->{defined_names}{$name} or croak "add_defined_name(): name '$name' already in use";
274 2         8 $self->{defined_names}{$name} = [$formula, $comment];
275             }
276              
277              
278             sub worksheet_rels {
279 15     15 0 48 my ($self, $table_id) = @_;
280              
281 15         26 my @rels;
282 15 100       68 push @rels, "officeDocument/2006/relationships/table" => "../tables/table$table_id.xml" if $table_id;
283 15         61 return $self->relationships(@rels);
284             }
285              
286              
287             #======================================================================
288             # BUILDING THE ZIP CONTENTS
289             #======================================================================
290              
291             sub save_as {
292 5     5 1 1192 my ($self, $target) = @_;
293              
294             # assemble all parts within the zip, except sheets and tables that were already added previously
295 5         13 my $zip = $self->{zip};
296 5         19 $zip->addString($self->content_types, "[Content_Types].xml" , $self->{compression_level});
297 5         1019 $zip->addString($self->core, "docProps/core.xml" , $self->{compression_level});
298 5         1011 $zip->addString($self->app, "docProps/app.xml" , $self->{compression_level});
299 5         1050 $zip->addString($self->workbook, "xl/workbook.xml" , $self->{compression_level});
300 5         1062 $zip->addString($self->_rels, "_rels/.rels" , $self->{compression_level});
301 5         996 $zip->addString($self->workbook_rels, "xl/_rels/workbook.xml.rels" , $self->{compression_level});
302 5         1045 $zip->addString($self->shared_strings, "xl/sharedStrings.xml" , $self->{compression_level});
303 5         1076 $zip->addString($self->styles, "xl/styles.xml" , $self->{compression_level});
304              
305             # write the Zip archive
306 5 100       994 my $write_result = ref $target ? $zip->writeToFileHandle($target) : $zip->writeToFileNamed($target);
307 5 50 0     1088954 $write_result == AZ_OK
308             or croak "could not save Zip archive into " . (ref($target) || $target);
309             }
310              
311              
312             sub _rels {
313 5     5   19 my ($self) = @_;
314              
315 5         24 return $self->relationships("officeDocument/2006/relationships/extended-properties" => "docProps/app.xml",
316             "package/2006/relationships/metadata/core-properties" => "docProps/core.xml",
317             "officeDocument/2006/relationships/officeDocument" => "xl/workbook.xml");
318             }
319              
320             sub workbook_rels {
321 5     5 0 14 my ($self) = @_;
322              
323 5         18 my @rels = map {("officeDocument/2006/relationships/worksheet" => "worksheets/sheet$_.xml")}
  15         60  
324             1 .. $self->n_sheets;
325 5         19 push @rels, "officeDocument/2006/relationships/sharedStrings" => "sharedStrings.xml",
326             "officeDocument/2006/relationships/styles" => "styles.xml";
327              
328 5         16 return $self->relationships(@rels);
329             }
330              
331              
332             sub workbook {
333 5     5 0 15 my ($self) = @_;
334              
335             # opening XML
336 5         17 my @xml = (
337             qq{},
338             qq{
339             qq{ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">},
340             );
341              
342             # references to the worksheets
343 5         13 push @xml, q{};
344 5         13 my $sheet_id = 1;
345 5         30 foreach my $sheet_name (@{$self->{sheets}}) {
  5         35  
346 15         53 push @xml, qq{};
347 15         35 $sheet_id++;
348             }
349 5         25 push @xml, q{};
350              
351 5 50       28 if (my $names = $self->{defined_names}) {
352 5         13 push @xml, q{};
353 5         29 while (my ($name, $content) = each %$names) {
354 2         7 my $attrs = qq{name="$name"};
355 2 100       9 $attrs .= qq{ comment="$content->[1]"} if $content->[1];
356 2         44 $content->[0] =~ s/($entity_regex)/$entity{$1}/g;
357 2         14 push @xml, qq{$content->[0]};
358             }
359 5         14 push @xml, q{};
360             }
361              
362              
363             # closing XML
364 5         13 push @xml, q{};
365              
366 5         54 return encode_utf8(join "", @xml);
367             }
368              
369              
370             sub content_types {
371 5     5 0 15 my ($self) = @_;
372              
373 5         12 my $spreadsheetml = "application/vnd.openxmlformats-officedocument.spreadsheetml";
374              
375             my @sheets_xml
376 5         19 = map {qq{}} 1 .. $self->n_sheets;
  15         56  
377              
378             my @tables_xml
379 5         33 = map {qq{ }} 1 .. $self->n_tables;
  9         41  
380              
381 5         44 my @xml = (
382             qq{},
383             qq{},
384             qq{},
385             qq{},
386             qq{},
387             qq{},
388             qq{},
389             qq{},
390             qq{},
391             @sheets_xml,
392             @tables_xml,
393             qq{},
394             );
395              
396 5         54 return join "", @xml;
397             }
398              
399              
400             sub core {
401 5     5 0 15 my ($self) = @_;
402              
403 5         247 my $now = strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
404              
405 5         72 my @xml = (
406             qq{},
407             qq{
408             qq{ xmlns:dc="http://purl.org/dc/elements/1.1/"},
409             qq{ xmlns:dcterms="http://purl.org/dc/terms/"},
410             qq{ xmlns:dcmitype="http://purl.org/dc/dcmitype/"},
411             qq{ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">},
412             qq{$now},
413             qq{$now},
414             qq{},
415             );
416              
417 5         61 return join "", @xml;
418             }
419              
420             sub app {
421 5     5 0 16 my ($self) = @_;
422              
423 5         22 my @xml = (
424             qq{},
425             qq{
426             qq{ xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">},
427             qq{Microsoft Excel},
428             qq{},
429             );
430              
431 5         52 return join "", @xml;
432             }
433              
434              
435              
436              
437             sub shared_strings {
438 5     5 0 18 my ($self) = @_;
439              
440             # array of XML nodes for each shared string
441 5         10 my @si_nodes;
442 5         11 $si_nodes[$self->{shared_strings}{$_}] = si_node($_) foreach keys %{$self->{shared_strings}};
  5         149  
443              
444             # assemble XML
445 5         122 my @xml = (
446             qq{},
447             qq{
448             qq{ count="$self->{n_strings_in_workbook}" uniqueCount="$self->{last_string_id}">},
449             @si_nodes,
450             qq{},
451             );
452              
453 5         161 return encode_utf8(join "", @xml);
454             }
455              
456              
457             sub styles {
458 5     5 0 16 my ($self) = @_;
459              
460             # minimal stylesheet
461             # style "1" will be used for displaying dates; it uses the default numFmtId for dates, which is 14 (Excel builtin).
462             # other nodes are empty but must be present
463 5         27 my @xml = (
464             q{},
465             q{},
466             q{},
467             q{},
468             q{},
469             q{},
470             q{},
471             q{},
472             q{},
473             );
474              
475 5         22 my $xml = join "", @xml;
476              
477 5         32 return $xml;
478             }
479              
480              
481             #======================================================================
482             # UTILITY METHODS
483             #======================================================================
484              
485             sub relationships {
486 25     25 0 76 my ($self, @rels) = @_;
487              
488             # build a "rel" file from a list of relationships
489 25         54 my @xml = (
490             qq{},
491             qq{},
492             );
493              
494 25         46 my $id = 1;
495 25         103 while (my ($type, $target) = splice(@rels, 0, 2)) {
496 49         179 push @xml, qq{};
497 49         145 $id++;
498             }
499              
500 25         51 push @xml, qq{};
501              
502 25         212 return join "", @xml;
503             }
504              
505              
506             sub n_sheets {
507 25     25 0 51 my ($self) = @_;
508 25         39 return scalar @{$self->{sheets}};
  25         63  
509             }
510              
511             sub n_tables {
512 14     14 0 36 my ($self) = @_;
513 14         21 return scalar @{$self->{tables}};
  14         41  
514             }
515              
516              
517             #======================================================================
518             # UTILITY ROUTINES
519             #======================================================================
520              
521              
522             sub si_node {
523 650     650 0 1068 my ($string) = @_;
524              
525             # build XML node for a single shared string
526 650         1506 $string =~ s/($entity_regex)/$entity{$1}/g;
527 650 50       1735 my $maybe_preserve_space = $string =~ /^\s|\s$/ ? ' xml:space="preserve"' : '';
528 650         1286 my $node = qq{$string};
529              
530 650         1753 return $node;
531             }
532              
533             sub escape_formula {
534 2     2 0 9 my ($string) = @_;
535              
536 2         8 $string =~ s/^=//;
537 2         45 $string =~ s/($entity_regex)/$entity{$1}/g;
538 2         9 return $string;
539             }
540              
541              
542             sub n_days {
543 14     14 0 97 my ($y, $m, $d) = @_;
544              
545             # convert the given date into a number of days since 1st January 1900
546 14         57 my $n_days = Delta_Days(1900, 1, 1, $y, $m, $d) + 1;
547 14         28 my $is_after_february_1900 = $n_days > 59;
548 14 100       28 $n_days += 1 if $is_after_february_1900; # because Excel wrongly treats 1900 as a leap year
549              
550 14         32 return $n_days;
551             }
552              
553              
554              
555              
556              
557             1;
558              
559             __END__