File Coverage

blib/lib/Excel/ValueWriter/XLSX.pm
Criterion Covered Total %
statement 217 229 94.7
branch 40 52 76.9
condition 22 32 68.7
subroutine 38 39 97.4
pod 5 21 23.8
total 322 373 86.3


line stmt bran cond sub pod time code
1 4     4   273107 use 5.014;
  4         42  
2             package Excel::ValueWriter::XLSX;
3 4     4   24 use strict;
  4         8  
  4         97  
4 4     4   19 use warnings;
  4         18  
  4         117  
5 4     4   26 use utf8;
  4         7  
  4         37  
6 4     4   3127 use Archive::Zip qw/AZ_OK COMPRESSION_LEVEL_DEFAULT/;
  4         371659  
  4         257  
7 4     4   38 use Scalar::Util qw/looks_like_number/;
  4         11  
  4         230  
8 4     4   25 use List::Util qw/none/;
  4         9  
  4         440  
9 4     4   2302 use Params::Validate qw/validate_with SCALAR SCALARREF UNDEF/;
  4         38356  
  4         649  
10 4     4   2215 use POSIX qw/strftime/;
  4         26413  
  4         35  
11 4     4   7859 use Date::Calc qw/Delta_Days/;
  4         23580  
  4         324  
12 4     4   33 use Carp qw/croak/;
  4         9  
  4         185  
13 4     4   25 use Encode qw/encode_utf8/;
  4         10  
  4         3540  
14              
15             our $VERSION = '1.04';
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 15825 my $class = shift;
56              
57             # check parameters and create $self
58 5         133 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         16 $self->{shared_string} = {}; # ($string => $string_index)
67 5         14 $self->{n_strings_in_workbook} = 0; # total nb of strings (including duplicates)
68 5         22 $self->{last_string_id} = 0; # index for the next shared string
69 5         13 $self->{defined_names} = {}; # ($name => [$formula, $comment])
70              
71             # immediately open a Zip archive
72 5         45 $self->{zip} = Archive::Zip->new;
73              
74             # return the constructed object
75 5         263 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 962 splice @_, 3, 0, undef if @_ < 5;
87              
88             # now we can parse the parameters
89 15         51 my ($self, $sheet_name, $table_name, $headers, $code_or_array) = @_;
90              
91             # check if the given sheet name is valid
92 15 50       147 $sheet_name =~ $SHEET_NAME
93             or croak "'$sheet_name' is not a valid sheet name";
94 15 50   30   77 none {$sheet_name eq $_} @{$self->{sheets}}
  30         61  
  15         84  
95             or croak "this workbook already has a sheet named '$sheet_name'";
96              
97             # local copies for convenience
98 15         64 my $date_regex = $self->{date_regex};
99 15         29 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   47   62 : do {my $i = 0; sub { $i < @$code_or_array ? $code_or_array->[$i++] : undef}};
  13 50       26  
  13 100       60  
  47         178  
106              
107             # if $headers were not given explicitly, the first row will do
108 15   100     57 $headers //= $next_row->();
109              
110             # array of column references in A1 Excel notation
111 15         40 my @col_letters = ('A'); # this array will be expanded on demand in the loop below
112              
113             # register the sheet name
114 15         24 push @{$self->{sheets}}, $sheet_name;
  15         35  
115              
116             # start building XML for the sheet
117 15         39 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         26 my $row_num = 0;
126             ROW:
127 15         45 for (my $row = $headers; $row; $row = $next_row->()) {
128 1041         296561 $row_num++;
129 1041 50       2670 my $last_col = @$row or next ROW;
130 1041         1616 my @cells;
131              
132             COLUMN:
133 1041         2760 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 300707   66     632248 //= do {my $prev_letter = $col_letters[$col-1]; ++$prev_letter};
  615         1009  
  615         1568  
138              
139             # get the value; if the cell is empty, no need to write it into the XML
140 300707         414991 my $val = $row->[$col];
141 300707 100 66     1163207 defined $val and length $val or next COLUMN;
142              
143             # choose XML attributes and inner value
144             # NOTE : for perl, looks_like_number( "INFINITY") is TRUE! Hence the test $val !~ /^\pL/
145             (my $tag, my $attrs, $val)
146 4     4   28 = looks_like_number($val) && $val !~ /^\pL/ ? (v => "" , $val )
  4         12  
  4         112  
147 4 100 100 4   90705 : $date_regex && $val =~ $date_regex ? (v => qq{ s="$DATE_STYLE"}, n_days($+{y}, $+{m}, $+{d}) )
  4 100 66     1640  
  4 100 100     9539  
  300705 100       1508513  
    100          
148             : $bool_regex && $val =~ $bool_regex ? (v => qq{ t="b"} , $1 ? 1 : 0 )
149             : $val =~ /^=/ ? (f => "", escape_formula($val) )
150             : (v => qq{ t="s"} , $self->add_shared_string($val));
151              
152             # add the new XML cell
153 300705         1343912 my $cell = sprintf qq{<%s>%s}, $col_letter, $row_num, $attrs, $tag, $val, $tag;
154 300705         692956 push @cells, $cell;
155             }
156              
157             # generate the row XML and add it to the sheet
158 1041         41497 my $row_xml = join "", qq{}, @cells, qq{};
159 1041         20045 push @xml, $row_xml;
160             }
161              
162             # close sheet data
163 15         49 push @xml, q{};
164              
165             # if required, add the table corresponding to this sheet into the zip archive, and refer to it in XML
166 15         29 my @table_rels;
167 15 100 100     60 if ($table_name && $row_num) {
168 9         78 my $table_id = $self->add_table($table_name, $col_letters[-1], $row_num, @$headers);
169 9         30 push @table_rels, $table_id;
170 9         25 push @xml, q{};
171             }
172              
173             # close the worksheet xml
174 15         43 push @xml, q{};
175              
176             # insert the sheet and its rels into the zip archive
177 15         55 my $sheet_id = $self->n_sheets;
178 15         52 my $sheet_file = "sheet$sheet_id.xml";
179             $self->{zip}->addString(encode_utf8(join("", @xml)),
180             "xl/worksheets/$sheet_file",
181 15         60734 $self->{compression_level});
182             $self->{zip}->addString($self->worksheet_rels(@table_rels),
183             "xl/worksheets/_rels/$sheet_file.rels",
184 15         18962 $self->{compression_level});
185              
186 15         3773 return $sheet_id;
187             }
188              
189              
190              
191             sub add_sheets_from_database {
192 0     0 1 0 my ($self, $dbh, $sheet_prefix, @table_names) = @_;
193              
194             # in absence of table names, get them from the database metadata
195 0 0       0 if (!@table_names) {
196 0         0 my $tables = $dbh->table_info(undef, undef, undef, 'TABLE')->fetchall_arrayref({});
197 0         0 @table_names = map {$_->{TABLE_NAME}} @$tables;
  0         0  
198             }
199              
200 0   0     0 $sheet_prefix //= "S.";
201              
202 0         0 foreach my $table (@table_names) {
203 0         0 my $sth = $dbh->prepare("select * from $table");
204 0         0 $sth->execute;
205 0         0 my $headers = $sth->{NAME};
206 0         0 my $rows = $sth->fetchall_arrayref;
207 0         0 $self->add_sheet("$sheet_prefix$table", $table, $headers, $rows);
208             }
209             }
210              
211              
212              
213             sub add_shared_string {
214 657     657 0 1361 my ($self, $string) = @_;
215              
216             # single quote before an initial equal sign is ignored (escaping the '=' like in Excel)
217 657         955 $string =~ s/^'=/=/;
218              
219             # keep a global count of how many strings are in the workbook
220 657         984 $self->{n_strings_in_workbook}++;
221              
222             # if that string was already stored, return its id, otherwise create a new id
223 657   100     3394 $self->{shared_strings}{$string} //= $self->{last_string_id}++;
224             }
225              
226              
227              
228             sub add_table {
229 9     9 0 175 my ($self, $table_name, $last_col, $last_row, @col_names) = @_;
230              
231             # check if the given table name is valid
232 9 50       109 $table_name =~ $TABLE_NAME
233             or croak "'$table_name' is not a valid table name";
234 9 50   6   56 none {$table_name eq $_} @{$self->{tables}}
  6         22  
  9         53  
235             or croak "this workbook already has a table named '$table_name'";
236              
237             # register this table
238 9         35 push @{$self->{tables}}, $table_name;
  9         32  
239 9         59 my $table_id = $self->n_tables;
240              
241             # build column headers from first data row
242 9         63 unshift @col_names, undef; # so that the first index is at 1, not 0
243 9         50 my @columns = map {qq{}} 1 .. $#col_names;
  618         1558  
244              
245             # Excel range of this table
246 9         85 my $ref = "A1:$last_col$last_row";
247              
248             # assemble XML for the table
249 9         165 my @xml = (
250             qq{},
251             qq{
252             qq{ id="$table_id" displayName="$table_name" ref="$ref" totalsRowShown="0">},
253             qq{},
254             qq{},
255             @columns,
256             qq{},
257             qq{},
258             qq{
},
259             );
260              
261             # insert into the zip archive
262             $self->{zip}->addString(encode_utf8(join "", @xml),
263             "xl/tables/table$table_id.xml",
264 9         367 $self->{compression_level});
265              
266 9         2962 return $table_id;
267             }
268              
269              
270             sub add_defined_name {
271 2     2 1 17 my ($self, $name, $formula, $comment) = @_;
272              
273 2 50 33     13 $name && $formula or croak 'add_defined_name($name, $formula): empty argument';
274 2 50       8 not exists $self->{defined_names}{$name} or croak "add_defined_name(): name '$name' already in use";
275 2         9 $self->{defined_names}{$name} = [$formula, $comment];
276             }
277              
278              
279             sub worksheet_rels {
280 15     15 0 51 my ($self, $table_id) = @_;
281              
282 15         26 my @rels;
283 15 100       71 push @rels, "officeDocument/2006/relationships/table" => "../tables/table$table_id.xml" if $table_id;
284 15         49 return $self->relationships(@rels);
285             }
286              
287              
288             #======================================================================
289             # BUILDING THE ZIP CONTENTS
290             #======================================================================
291              
292             sub save_as {
293 5     5 1 1232 my ($self, $target) = @_;
294              
295             # assemble all parts within the zip, except sheets and tables that were already added previously
296 5         12 my $zip = $self->{zip};
297 5         26 $zip->addString($self->content_types, "[Content_Types].xml" , $self->{compression_level});
298 5         1370 $zip->addString($self->core, "docProps/core.xml" , $self->{compression_level});
299 5         1012 $zip->addString($self->app, "docProps/app.xml" , $self->{compression_level});
300 5         942 $zip->addString($self->workbook, "xl/workbook.xml" , $self->{compression_level});
301 5         972 $zip->addString($self->_rels, "_rels/.rels" , $self->{compression_level});
302 5         1010 $zip->addString($self->workbook_rels, "xl/_rels/workbook.xml.rels" , $self->{compression_level});
303 5         982 $zip->addString($self->shared_strings, "xl/sharedStrings.xml" , $self->{compression_level});
304 5         1026 $zip->addString($self->styles, "xl/styles.xml" , $self->{compression_level});
305              
306             # write the Zip archive
307 5 100       962 my $write_result = ref $target ? $zip->writeToFileHandle($target) : $zip->writeToFileNamed($target);
308 5 50 0     1079964 $write_result == AZ_OK
309             or croak "could not save Zip archive into " . (ref($target) || $target);
310             }
311              
312              
313             sub _rels {
314 5     5   15 my ($self) = @_;
315              
316 5         24 return $self->relationships("officeDocument/2006/relationships/extended-properties" => "docProps/app.xml",
317             "package/2006/relationships/metadata/core-properties" => "docProps/core.xml",
318             "officeDocument/2006/relationships/officeDocument" => "xl/workbook.xml");
319             }
320              
321             sub workbook_rels {
322 5     5 0 17 my ($self) = @_;
323              
324 5         19 my @rels = map {("officeDocument/2006/relationships/worksheet" => "worksheets/sheet$_.xml")}
  15         56  
325             1 .. $self->n_sheets;
326 5         23 push @rels, "officeDocument/2006/relationships/sharedStrings" => "sharedStrings.xml",
327             "officeDocument/2006/relationships/styles" => "styles.xml";
328              
329 5         28 return $self->relationships(@rels);
330             }
331              
332              
333             sub workbook {
334 5     5 0 17 my ($self) = @_;
335              
336             # opening XML
337 5         39 my @xml = (
338             qq{},
339             qq{
340             qq{ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">},
341             );
342              
343             # references to the worksheets
344 5         25 push @xml, q{};
345 5         13 my $sheet_id = 1;
346 5         9 foreach my $sheet_name (@{$self->{sheets}}) {
  5         18  
347 15         56 push @xml, qq{};
348 15         29 $sheet_id++;
349             }
350 5         15 push @xml, q{};
351              
352 5 50       21 if (my $names = $self->{defined_names}) {
353 5         10 push @xml, q{};
354 5         30 while (my ($name, $content) = each %$names) {
355 2         7 my $attrs = qq{name="$name"};
356 2 100       10 $attrs .= qq{ comment="$content->[1]"} if $content->[1];
357 2         43 $content->[0] =~ s/($entity_regex)/$entity{$1}/g;
358 2         15 push @xml, qq{$content->[0]};
359             }
360 5         15 push @xml, q{};
361             }
362              
363              
364             # closing XML
365 5         9 push @xml, q{};
366              
367 5         58 return encode_utf8(join "", @xml);
368             }
369              
370              
371             sub content_types {
372 5     5 0 15 my ($self) = @_;
373              
374 5         12 my $spreadsheetml = "application/vnd.openxmlformats-officedocument.spreadsheetml";
375              
376             my @sheets_xml
377 5         20 = map {qq{}} 1 .. $self->n_sheets;
  15         63  
378              
379             my @tables_xml
380 5         22 = map {qq{ }} 1 .. $self->n_tables;
  9         40  
381              
382 5         58 my @xml = (
383             qq{},
384             qq{},
385             qq{},
386             qq{},
387             qq{},
388             qq{},
389             qq{},
390             qq{},
391             qq{},
392             @sheets_xml,
393             @tables_xml,
394             qq{},
395             );
396              
397 5         58 return join "", @xml;
398             }
399              
400              
401             sub core {
402 5     5 0 26 my ($self) = @_;
403              
404 5         226 my $now = strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
405              
406 5         51 my @xml = (
407             qq{},
408             qq{
409             qq{ xmlns:dc="http://purl.org/dc/elements/1.1/"},
410             qq{ xmlns:dcterms="http://purl.org/dc/terms/"},
411             qq{ xmlns:dcmitype="http://purl.org/dc/dcmitype/"},
412             qq{ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">},
413             qq{$now},
414             qq{$now},
415             qq{},
416             );
417              
418 5         46 return join "", @xml;
419             }
420              
421             sub app {
422 5     5 0 23 my ($self) = @_;
423              
424 5         23 my @xml = (
425             qq{},
426             qq{
427             qq{ xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">},
428             qq{Microsoft Excel},
429             qq{},
430             );
431              
432 5         33 return join "", @xml;
433             }
434              
435              
436              
437              
438             sub shared_strings {
439 5     5 0 14 my ($self) = @_;
440              
441             # array of XML nodes for each shared string
442 5         11 my @si_nodes;
443 5         8 $si_nodes[$self->{shared_strings}{$_}] = si_node($_) foreach keys %{$self->{shared_strings}};
  5         155  
444              
445             # assemble XML
446 5         126 my @xml = (
447             qq{},
448             qq{
449             qq{ count="$self->{n_strings_in_workbook}" uniqueCount="$self->{last_string_id}">},
450             @si_nodes,
451             qq{},
452             );
453              
454 5         164 return encode_utf8(join "", @xml);
455             }
456              
457              
458             sub styles {
459 5     5 0 13 my ($self) = @_;
460              
461             # minimal stylesheet
462             # style "1" will be used for displaying dates; it uses the default numFmtId for dates, which is 14 (Excel builtin).
463             # other nodes are empty but must be present
464 5         28 my @xml = (
465             q{},
466             q{},
467             q{},
468             q{},
469             q{},
470             q{},
471             q{},
472             q{},
473             q{},
474             );
475              
476 5         47 my $xml = join "", @xml;
477              
478 5         26 return $xml;
479             }
480              
481              
482             #======================================================================
483             # UTILITY METHODS
484             #======================================================================
485              
486             sub relationships {
487 25     25 0 71 my ($self, @rels) = @_;
488              
489             # build a "rel" file from a list of relationships
490 25         61 my @xml = (
491             qq{},
492             qq{},
493             );
494              
495 25         35 my $id = 1;
496 25         93 while (my ($type, $target) = splice(@rels, 0, 2)) {
497 49         185 push @xml, qq{};
498 49         154 $id++;
499             }
500              
501 25         51 push @xml, qq{};
502              
503 25         192 return join "", @xml;
504             }
505              
506              
507             sub n_sheets {
508 25     25 0 55 my ($self) = @_;
509 25         44 return scalar @{$self->{sheets}};
  25         69  
510             }
511              
512             sub n_tables {
513 14     14 0 37 my ($self) = @_;
514 14         24 return scalar @{$self->{tables}};
  14         40  
515             }
516              
517              
518             #======================================================================
519             # UTILITY ROUTINES
520             #======================================================================
521              
522              
523             sub si_node { # build XML node for a single shared string
524 653     653 0 1058 my ($string) = @_;
525              
526             # escape XML entities
527 653         1510 $string =~ s/($entity_regex)/$entity{$1}/g;
528              
529              
530             # Excel escapes control characters with _xHHHH_ and also escapes any
531             # literal strings of that type by encoding the leading underscore. So
532             # "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_.
533             # The following substitutions deal with those cases.
534             # This code is borrowed from Excel::Writer::XLSX::Package::SharedStrings -- thank you, John McNamara
535              
536             # Escape the escape.
537 653         916 $string =~ s/(_x[0-9a-fA-F]{4}_)/_x005F$1/g;
538              
539             # Convert control character to the _xHHHH_ escape.
540 653         914 $string =~ s/([\x00-\x08\x0B-\x1F])/sprintf "_x%04X_", ord($1)/eg;
  3         16  
541              
542 653 100       1637 my $maybe_preserve_space = $string =~ /^\s|\s$/ ? ' xml:space="preserve"' : '';
543 653         1310 my $node = qq{$string};
544              
545 653         1839 return $node;
546             }
547              
548             sub escape_formula {
549 2     2 0 9 my ($string) = @_;
550              
551 2         8 $string =~ s/^=//;
552 2         44 $string =~ s/($entity_regex)/$entity{$1}/g;
553 2         9 return $string;
554             }
555              
556              
557             sub n_days {
558 14     14 0 91 my ($y, $m, $d) = @_;
559              
560             # convert the given date into a number of days since 1st January 1900
561 14         58 my $n_days = Delta_Days(1900, 1, 1, $y, $m, $d) + 1;
562 14         29 my $is_after_february_1900 = $n_days > 59;
563 14 100       29 $n_days += 1 if $is_after_february_1900; # because Excel wrongly treats 1900 as a leap year
564              
565 14         33 return $n_days;
566             }
567              
568              
569              
570              
571              
572             1;
573              
574             __END__