File Coverage

blib/lib/Excel/ValueWriter/XLSX.pm
Criterion Covered Total %
statement 221 241 91.7
branch 40 60 66.6
condition 26 53 49.0
subroutine 38 41 92.6
pod 5 21 23.8
total 330 416 79.3


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