File Coverage

blib/lib/Excel/ValueWriter/XLSX.pm
Criterion Covered Total %
statement 260 267 97.3
branch 51 68 75.0
condition 41 67 61.1
subroutine 45 46 97.8
pod 5 20 25.0
total 402 468 85.9


specification applies
line stmt bran cond sub pod time code
1 4     4   474885 use 5.014;
  4         17  
2             package Excel::ValueWriter::XLSX;
3 4     4   40 use strict;
  4         8  
  4         128  
4 4     4   20 use warnings;
  4         19  
  4         205  
5 4     4   35 use utf8;
  4         10  
  4         37  
6 4     4   4583 use Archive::Zip qw/AZ_OK COMPRESSION_LEVEL_DEFAULT/;
  4         401258  
  4         269  
7 4     4   33 use Scalar::Util qw/looks_like_number blessed/;
  4         7  
  4         247  
8 4     4   17 use List::Util qw/none/;
  4         8  
  4         415  
9 4     4   1681 use POSIX qw/strftime/;
  4         24013  
  4         46  
10 4     4   7645 use Date::Calc qw/Delta_Days/;
  4         25485  
  4         368  
11 4     4   29 use Carp qw/croak/;
  4         4  
  4         159  
12 4     4   17 use Encode qw/encode_utf8/;
  4         6  
  4         156  
13 4     4   4237 use Data::Domain 1.16 qw/:all/;
  4         983245  
  4         26  
14 4     4   9065 use Try::Tiny;
  4         7  
  4         20582  
15              
16             our $VERSION = '1.10';
17              
18             #======================================================================
19             # GLOBALS
20             #======================================================================
21              
22             my $DATE_STYLE = 1; # 0-based index into the format for dates ..
23             # .. defined in the styles() method
24              
25             my $RX_SHEET_NAME = qr(^[^\\/?*\[\]]{1,31}$); # valid sheet names: <32 chars, no chars \/?*[]
26             my $RX_TABLE_NAME = qr(^\w{3,}$); # valid table names: >= 3 chars, no spaces
27              
28             my %entity = ( '<' => '<', '>' => '>', '&' => '&' );
29             my $entity_regex = do {my $chars = join "", keys %entity; qr/[$chars]/};
30              
31             #======================================================================
32             # SIGNATURES FOR CONTROLLING ARGS TO PUBLIC METHODS
33             #======================================================================
34              
35             my $sig_for_new = Struict( # Struict = strict Struct .. not a typo!
36              
37             # date_regex : for identifying dates in data cells. Should capture into $+{d}, $+{m} and $+{y}.
38             date_regex => Regexp(-if_absent =>
39             qr[^(?: (?\d\d?) \. (?\d\d?) \. (?\d\d\d\d) # dd.mm.yyyy
40             | (?\d\d\d\d) - (?\d\d?) - (?\d\d?) # yyyy-mm-dd
41             | (?\d\d?) / (?\d\d?) / (?\d\d\d\d)) # mm/dd/yyyy
42             $]x),
43              
44             # bool_regex : for identifying booleans in data cells. If true, should capture into $1
45             bool_regex => Regexp(-if_absent => qr[^(?:(TRUE)|FALSE)$]),
46              
47             # ZIP compression level
48             compression_level => Int(-range => [1, 9], -if_absent => COMPRESSION_LEVEL_DEFAULT),
49             )->meth_signature;
50              
51              
52             my $sig_for_add_sheet = List(
53             String(-name => 'sheet_name', -regex => $RX_SHEET_NAME),
54             String(-name => 'table_name', -regex => $RX_TABLE_NAME, -optional => 1),
55             List (-name => 'headers', -all => String, -optional => 1),
56             One_of(-name => 'rows_maker', -options => [List, # an array of rows, or
57             Coderef, # a row generator coderef, or
58             Obj(-isa => 'DBI::st'), # a DBI statement, or
59             Obj(-isa => 'DBIx::DataModel::Statement'), # a DBIx::DataModel statement
60             ]),
61             Struict(-name => 'options', -optional => 1, -fields => {
62             cols => One_of(List(-all => Num),
63             List(-all => Struct(width => Num(-optional => 1),
64             style => Int(-optional => 1),
65             min => Int(-optional => 1),
66             max => Int(-optional => 1))))
67             }),
68             )->meth_signature;
69              
70             my $sig_for_add_sheets_from_database = List(-items => [Obj (-isa => 'DBI::db'),
71             String(-default => "S.")],
72             -all => String,
73             )->meth_signature;
74              
75             my $sig_for_add_defined_name = List(String(-name => "name"),
76             String(-name => "formula"),
77             String(-name => "comments", -optional => 1),
78             )->meth_signature;
79              
80              
81              
82             my $sig_for_save_as = One_of(String,
83             Whatever(-does => 'IO'),
84             )->meth_signature;
85              
86              
87            
88             #======================================================================
89             # CONSTRUCTOR
90             #======================================================================
91              
92             sub new {
93 6     6 1 1500240 my ($class, %self) = &$sig_for_new;
94              
95             # initial values for internal data structures (constructor args cannot initialize those)
96 6         6442 $self{sheets} = []; # array of sheet names
97 6         20 $self{tables} = []; # array of table names
98 6         24 $self{shared_string} = {}; # ($string => $string_index)
99 6         16 $self{n_strings_in_workbook} = 0; # total nb of strings (including duplicates)
100 6         29 $self{last_string_id} = 0; # index for the next shared string
101 6         16 $self{defined_names} = {}; # ($name => [$formula, $comment])
102              
103             # immediately open a Zip archive
104 6         139 $self{zip} = Archive::Zip->new;
105              
106             # return the constructed object
107 6         403 bless \%self, $class;
108             }
109              
110              
111             #======================================================================
112             # GATHERING DATA
113             #======================================================================
114              
115              
116             sub add_sheet {
117             # the 3rd parameter ($headers) may be omitted -- so we insert an undef if necessary
118 27 100 50 27 1 1657 splice @_, 3, 0, undef if @_ < 5 or @_ == 5 && (ref $_[4] // '') eq 'HASH';
      66        
      100        
119              
120             # now we can parse the parameters
121 27         145 my ($self, $sheet_name, $table_name, $headers, $rows_maker, $options) = &$sig_for_add_sheet;
122              
123             # register the sheet name
124 27 50   91   89685 none {$sheet_name eq $_} @{$self->{sheets}}
  91         178  
  27         187  
125             or croak "this workbook already has a sheet named '$sheet_name'";
126 27         92 push @{$self->{sheets}}, $sheet_name;
  27         77  
127              
128             # iterator for generating rows
129 27         112 my $row_iterator = $self->_build_row_iterator($rows_maker, \$headers);
130              
131             # build inner XML
132 27         129 my ($xml, $last_row, $last_col) = $self->_build_rows($headers, $row_iterator, $table_name);
133              
134             # add XML preamble and close sheet data
135 27 100       136 my $ref = $last_row >= 1 ? "A1:$last_col$last_row" : "A1"; # range of cells in sheet
136 27         119 my $preamble = join "",
137             q{},
138             q{
139             q{ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">},
140             qq{};
141 27 100       92 $preamble .= $self->_xml_for_options($options) if $options;
142 27         53 $preamble .= q{};
143 27         15532 substr $xml, 0, 0, $preamble;
144 27         88 $xml .= q{};
145              
146             # if required, add the table corresponding to this sheet into the zip archive, and refer to it in XML
147 27         102 my @table_rels;
148 27 100 100     131 if ($table_name && $headers) {
149 21         210 my $table_id = $self->_add_table($table_name, $last_col, $last_row, @$headers);
150 21         64 push @table_rels, $table_id;
151 21         75 $xml .= q{};
152             }
153              
154             # close the worksheet xml
155 27         54 $xml .= q{};
156              
157             # insert the sheet and its rels into the zip archive
158 27         116 my $sheet_id = $self->n_sheets;
159 27         63 my $sheet_file = "sheet$sheet_id.xml";
160 27         21818 $self->add_string_to_zip(encode_utf8($xml), "xl/worksheets/$sheet_file" );
161 27         29682 $self->add_string_to_zip($self->worksheet_rels(@table_rels), "xl/worksheets/_rels/$sheet_file.rels");
162              
163 27         6278 return $sheet_id;
164             }
165              
166             sub _build_row_iterator {
167 27     27   79 my ($self, $rows_maker, $headers_ref) = @_;
168              
169 27         50 my $iterator;
170              
171 27         70 my $ref = ref $rows_maker;
172 27 100 66     314 if ($ref && $ref eq 'CODE') {
    100 66        
    50 33        
    0 0        
173 2         38 $iterator = $rows_maker;
174 2   33     12 $$headers_ref //= $iterator->();
175             }
176             elsif ($ref && $ref eq 'ARRAY') {
177 14         28 my $i = 0;
178 14 100   49   55 $iterator = sub { $i < @$rows_maker ? $rows_maker->[$i++] : undef};
  49         280  
179 14   100     58 $$headers_ref //= $iterator->();
180             }
181             elsif (blessed $rows_maker && $rows_maker->isa('DBI::st')) {
182             $rows_maker->{Executed}
183 11 50       162 or croak '->add_sheet(..., $sth) : the statement handle must be executed (call the $sth->execute method)';
184 11     15618   51 $iterator = sub { $rows_maker->fetchrow_arrayref};
  15618         105217  
185 11   33     123 $$headers_ref //= $rows_maker->{NAME}; # see L
186             }
187             elsif (blessed $rows_maker && $rows_maker->isa('DBIx::DataModel::Statement')) {
188 0 0       0 DBIx::DataModel->VERSION >= 3.0
189             or croak 'add_sheet(.., $statement) : requires DBIx::DataModel >= 3.0; your version is ', DBIx::DataModel->VERSION;
190 0   0     0 $$headers_ref //= $rows_maker->sth->{NAME};
191 0 0   0   0 $iterator = sub {my $row = $rows_maker->next; return $row ? [@{$row}{@$$headers_ref}] : ()};
  0         0  
  0         0  
  0         0  
192             }
193             else {
194 0         0 croak 'add_sheet() : missing or invalid last argument ($rows_maker)';
195             }
196              
197 27         86 return $iterator;
198             }
199              
200             sub _build_rows {
201 27     27   77 my ($self, $headers, $row_iterator, $table_name) = @_;
202              
203 27         56 my $xml = "";
204              
205             # local copies for convenience
206 27         63 my $date_regex = $self->{date_regex};
207 27         51 my $bool_regex = $self->{bool_regex};
208              
209             # array of column references in A1 Excel notation
210 27         93 my @col_letters = ('A'); # this array will be expanded on demand in the loop below
211              
212             # loop over rows and columns
213 27         54 my $row_num = 0;
214             ROW:
215 27         183 for (my $row = $headers; $row; $row = $row_iterator->()) {
216 16661         387062 $row_num++;
217 16661 50       34456 my $last_col = @$row or next ROW;
218 16661         22798 my @cells;
219              
220             COLUMN:
221 16661         34808 foreach my $col (0 .. $last_col-1) {
222              
223             # if this column letter is not known yet, compute it using Perl's increment op on strings (so 'AA' comes after 'Z')
224             my $col_letter = $col_letters[$col]
225 367214   66     711982 //= do {my $prev_letter = $col_letters[$col-1]; ++$prev_letter};
  669         1205  
  669         2284  
226              
227             # get the value; if the cell is empty, no need to write it into the XML
228 367214         535431 my $val = $row->[$col];
229 367214 100 66     1348625 defined $val and length $val or next COLUMN;
230 365874         496770 my $n_days; # in case we need to parse a date
231              
232             # choose XML attributes and inner value
233             # NOTE : for perl, looks_like_number( "INFINITY") is TRUE! Hence the test $val !~ /^\pL/
234 365874 100 100     1759818 ( my $tag, my $attrs, $val)
    100 100        
    100 100        
    100          
    100          
235             # ==== ========= ====
236             = looks_like_number($val) && $val !~ /^\pL/ ? (v => "" , $val )
237             : $date_regex && $val =~ $date_regex
238             && is_valid_date(\%+, \$n_days) ? (v => qq{ s="$DATE_STYLE"}, $n_days )
239             : $bool_regex && $val =~ $bool_regex ? (v => qq{ t="b"} , $1 ? 1 : 0 )
240             : $val =~ /^=/ ? (f => "", escape_formula($val) )
241             : (v => qq{ t="s"} , $self->_add_shared_string($val));
242              
243             # add the new XML cell
244 365874         934808 my $cell = qq{<$tag>$val};
245 365874         714481 push @cells, $cell;
246             }
247              
248             # generate the row XML and add it to the sheet
249 16661         56181 my $row_xml = join "", qq{}, @cells, qq{};
250 16661         96217 $xml .= $row_xml;
251             }
252              
253             # if this sheet contains an Excel table, make sure there is at least one data row
254 27 100 33     159 ++$row_num and $xml .= qq{}
      100        
255             if $table_name && $row_num == 1;
256              
257 27         22836 return ($xml, $row_num, $col_letters[-1]);
258             }
259              
260              
261              
262             sub _xml_for_options {
263 1     1   4 my ($self, $options) = @_;
264              
265             # currently there is only one option 'cols'. Handled below in a separate sub for better clarity.
266 1         5 return $self->_xml_for_cols_option($options->{cols});
267             }
268              
269              
270             sub _xml_for_cols_option {
271 1     1   3 my ($self, $cols) = @_;
272              
273 1         2 my $xml = "";
274 1         3 my $next_col_num = 1;
275 1         3 foreach my $col (@$cols) {
276             # build attributes for the node
277 4 50       51 my %attrs = ref $col ? %$col : (width => $col); # cols => [6, ...] is just syntactic sugar for => [{width => 6}, ...]
278 4   33     56 $attrs{$_} //= $next_col_num for qw/min max/; # colrange to which this
279 4 50 50     21 $attrs{customWidth} //= 1 if $attrs{width}; # tells Excel that the width is not automatic
280              
281             # generate XML from attributes
282 4         13 $xml .= join(" ", "";
  16         43  
283              
284             # compute index of next column
285 4         17 $next_col_num = $attrs{max} + 1;
286             }
287 1         4 $xml .= "";
288              
289 1         5 return $xml;
290             }
291              
292              
293             sub add_sheets_from_database {
294 1     1 1 8 my ($self, $dbh, $sheet_prefix, @table_names) = &$sig_for_add_sheets_from_database;
295              
296             # in absence of table names, get them from the database metadata
297 1 50       961 if (!@table_names) {
298 1         16 my $tables = $dbh->table_info(undef, undef, undef, 'TABLE')->fetchall_arrayref({});
299 1         989 @table_names = map {$_->{TABLE_NAME}} @$tables;
  11         38  
300             }
301              
302 1         5 foreach my $table (@table_names) {
303 11         167 my $sth = $dbh->prepare("select * from $table");
304 11         2444 $sth->execute;
305 11         104 $self->add_sheet("$sheet_prefix$table", $table, $sth);
306             }
307             }
308              
309              
310              
311             sub _add_shared_string {
312 10024     10024   20038 my ($self, $string) = @_;
313              
314             # single quote before an initial equal sign is ignored (escaping the '=' like in Excel)
315 10024         15054 $string =~ s/^'=/=/;
316              
317             # keep a global count of how many strings are in the workbook
318 10024         15091 $self->{n_strings_in_workbook}++;
319              
320             # if that string was already stored, return its id, otherwise create a new id
321 10024   100     53058 $self->{shared_strings}{$string} //= $self->{last_string_id}++;
322             }
323              
324              
325              
326             sub _add_table {
327 21     21   286 my ($self, $table_name, $last_col, $last_row, @col_names) = @_;
328              
329             # register this table
330 21 50   64   131 none {$table_name eq $_} @{$self->{tables}}
  64         123  
  21         172  
331             or croak "this workbook already has a table named '$table_name'";
332 21         84 push @{$self->{tables}}, $table_name;
  21         135  
333 21         96 my $table_id = $self->n_tables;
334              
335             # build column headers from first data row
336 21         100 unshift @col_names, undef; # so that the first index is at 1, not 0
337 21         121 my @columns = map {qq{}} 1 .. $#col_names;
  684         1921  
338              
339             # Excel range of this table
340 21         114 my $ref = "A1:$last_col$last_row";
341              
342             # assemble XML for the table
343 21         283 my @xml = (
344             qq{},
345             qq{
346             qq{ id="$table_id" displayName="$table_name" ref="$ref" totalsRowShown="0">},
347             qq{},
348             qq{},
349             @columns,
350             qq{},
351             qq{},
352             qq{
},
353             );
354              
355             # insert into the zip archive
356 21         431 $self->add_string_to_zip(encode_utf8(join "", @xml), "xl/tables/table$table_id.xml");
357              
358 21         6737 return $table_id;
359             }
360              
361              
362             sub add_defined_name {
363 2     2 1 17 my ($self, $name, $formula, $comment) = &$sig_for_add_defined_name;
364              
365 2 50       1206 not exists $self->{defined_names}{$name} or croak "add_defined_name(): name '$name' already in use";
366 2         12 $self->{defined_names}{$name} = [$formula, $comment];
367             }
368              
369              
370             sub worksheet_rels {
371 27     27 0 94 my ($self, $table_id) = @_;
372              
373 27         52 my @rels;
374 27 100       115 push @rels, "officeDocument/2006/relationships/table" => "../tables/table$table_id.xml" if $table_id;
375 27         104 return $self->relationships(@rels);
376             }
377              
378              
379             #======================================================================
380             # BUILDING THE ZIP CONTENTS
381             #======================================================================
382              
383             sub save_as {
384 6     6 1 1299 my ($self, $target) = &$sig_for_save_as;
385              
386             # assemble all parts within the zip, except sheets and tables that were already added previously
387 6         18100 $self->add_string_to_zip($self->content_types, "[Content_Types].xml" );
388 6         1227 $self->add_string_to_zip($self->core, "docProps/core.xml" );
389 6         1031 $self->add_string_to_zip($self->app, "docProps/app.xml" );
390 6         1027 $self->add_string_to_zip($self->workbook, "xl/workbook.xml" );
391 6         1141 $self->add_string_to_zip($self->_rels, "_rels/.rels" );
392 6         1080 $self->add_string_to_zip($self->workbook_rels, "xl/_rels/workbook.xml.rels");
393 6         1190 $self->add_string_to_zip($self->shared_strings, "xl/sharedStrings.xml" );
394 6         1719 $self->add_string_to_zip($self->styles, "xl/styles.xml" );
395              
396             # write the Zip archive
397 6 100       1012 my $write_result = ref $target ? $self->{zip}->writeToFileHandle($target) : $self->{zip}->writeToFileNamed($target);
398 6 50 0     1583160 $write_result == AZ_OK
399             or croak "could not save Zip archive into " . (ref($target) || $target);
400             }
401              
402              
403             sub add_string_to_zip {
404 123     123 0 280 my ($self, $content, $name) = @_;
405              
406 123         613 $self->{zip}->addString($content, $name, $self->{compression_level});
407             }
408              
409              
410              
411             sub _rels {
412 6     6   17 my ($self) = @_;
413              
414 6         36 return $self->relationships("officeDocument/2006/relationships/extended-properties" => "docProps/app.xml",
415             "package/2006/relationships/metadata/core-properties" => "docProps/core.xml",
416             "officeDocument/2006/relationships/officeDocument" => "xl/workbook.xml");
417             }
418              
419             sub workbook_rels {
420 6     6 0 16 my ($self) = @_;
421              
422 6         24 my @rels = map {("officeDocument/2006/relationships/worksheet" => "worksheets/sheet$_.xml")}
  27         82  
423             1 .. $self->n_sheets;
424 6         23 push @rels, "officeDocument/2006/relationships/sharedStrings" => "sharedStrings.xml",
425             "officeDocument/2006/relationships/styles" => "styles.xml";
426              
427 6         22 return $self->relationships(@rels);
428             }
429              
430              
431             sub workbook {
432 6     6 0 17 my ($self) = @_;
433              
434             # opening XML
435 6         19 my @xml = (
436             qq{},
437             qq{
438             qq{ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">},
439             );
440              
441             # references to the worksheets
442 6         12 push @xml, q{};
443 6         2393 my $sheet_id = 1;
444 6         14 foreach my $sheet_name (@{$self->{sheets}}) {
  6         23  
445 27         61 push @xml, qq{};
446 27         43 $sheet_id++;
447             }
448 6         16 push @xml, q{};
449              
450 6 50       23 if (my $names = $self->{defined_names}) {
451 6         10 push @xml, q{};
452 6         32 while (my ($name, $content) = each %$names) {
453 2         8 my $attrs = qq{name="$name"};
454 2 100       9 $attrs .= qq{ comment="$content->[1]"} if $content->[1];
455 2         91 $content->[0] =~ s/($entity_regex)/$entity{$1}/g;
456 2         15 push @xml, qq{$content->[0]};
457             }
458 6         12 push @xml, q{};
459             }
460              
461              
462             # closing XML
463 6         15 push @xml, q{};
464              
465 6         64 return encode_utf8(join "", @xml);
466             }
467              
468              
469             sub content_types {
470 6     6 0 27 my ($self) = @_;
471              
472 6         16 my $spreadsheetml = "application/vnd.openxmlformats-officedocument.spreadsheetml";
473              
474             my @sheets_xml
475 6         21 = map {qq{}} 1 .. $self->n_sheets;
  27         94  
476              
477             my @tables_xml
478 6         23 = map {qq{ }} 1 .. $self->n_tables;
  21         45  
479              
480 6         47 my @xml = (
481             qq{},
482             qq{},
483             qq{},
484             qq{},
485             qq{},
486             qq{},
487             qq{},
488             qq{},
489             qq{},
490             @sheets_xml,
491             @tables_xml,
492             qq{},
493             );
494              
495 6         88 return join "", @xml;
496             }
497              
498              
499             sub core {
500 6     6 0 14 my ($self) = @_;
501              
502 6         170 my $now = strftime "%Y-%m-%dT%H:%M:%SZ", gmtime;
503              
504 6         55 my @xml = (
505             qq{},
506             qq{
507             qq{ xmlns:dc="http://purl.org/dc/elements/1.1/"},
508             qq{ xmlns:dcterms="http://purl.org/dc/terms/"},
509             qq{ xmlns:dcmitype="http://purl.org/dc/dcmitype/"},
510             qq{ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">},
511             qq{$now},
512             qq{$now},
513             qq{},
514             );
515              
516 6         43 return join "", @xml;
517             }
518              
519             sub app {
520 6     6 0 14 my ($self) = @_;
521              
522 6         31 my @xml = (
523             qq{},
524             qq{
525             qq{ xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">},
526             qq{Microsoft Excel},
527             qq{},
528             );
529              
530 6         41 return join "", @xml;
531             }
532              
533              
534              
535              
536             sub shared_strings {
537 6     6 0 16 my ($self) = @_;
538              
539             # array of XML nodes for each shared string
540 6         9 my @si_nodes;
541 6         11 $si_nodes[$self->{shared_strings}{$_}] = si_node($_) foreach keys %{$self->{shared_strings}};
  6         2522  
542              
543             # assemble XML
544 6         1309 my @xml = (
545             qq{},
546             qq{
547             qq{ count="$self->{n_strings_in_workbook}" uniqueCount="$self->{last_string_id}">},
548             @si_nodes,
549             qq{},
550             );
551              
552 6         1411 return encode_utf8(join "", @xml);
553             }
554              
555              
556             sub styles {
557 6     6 0 16 my ($self) = @_;
558              
559             # minimal stylesheet
560             # style "1" will be used for displaying dates; it uses the default numFmtId for dates, which is 14 (Excel builtin).
561             # other nodes are empty but must be present
562 6         31 my @xml = (
563             q{},
564             q{},
565             q{},
566             q{},
567             q{},
568             q{},
569             q{},
570             q{},
571             q{},
572             );
573              
574 6         24 my $xml = join "", @xml;
575              
576 6         25 return $xml;
577             }
578              
579              
580             #======================================================================
581             # UTILITY METHODS
582             #======================================================================
583              
584             sub relationships {
585 39     39 0 154 my ($self, @rels) = @_;
586              
587             # build a "rel" file from a list of relationships
588 39         180 my @xml = (
589             qq{},
590             qq{},
591             );
592              
593 39         66 my $id = 1;
594 39         183 while (my ($type, $target) = splice(@rels, 0, 2)) {
595 78         204 push @xml, qq{};
596 78         213 $id++;
597             }
598              
599 39         82 push @xml, qq{};
600              
601 39         334 return join "", @xml;
602             }
603              
604              
605             sub n_sheets {
606 39     39 0 84 my ($self) = @_;
607 39         60 return scalar @{$self->{sheets}};
  39         139  
608             }
609              
610             sub n_tables {
611 27     27 0 55 my ($self) = @_;
612 27         50 return scalar @{$self->{tables}};
  27         91  
613             }
614              
615              
616             #======================================================================
617             # UTILITY ROUTINES
618             #======================================================================
619              
620              
621             sub si_node { # build XML node for a single shared string
622 6212     6212 0 7419 my ($string) = @_;
623              
624             # escape XML entities
625 6212         11912 $string =~ s/($entity_regex)/$entity{$1}/g;
626              
627              
628             # Excel escapes control characters with _xHHHH_ and also escapes any
629             # literal strings of that type by encoding the leading underscore. So
630             # "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_.
631             # The following substitutions deal with those cases.
632             # This code is borrowed from Excel::Writer::XLSX::Package::SharedStrings -- thank you, John McNamara
633              
634             # Escape the escape.
635 6212         6775 $string =~ s/(_x[0-9a-fA-F]{4}_)/_x005F$1/g;
636              
637             # Convert control character to the _xHHHH_ escape.
638 6212         7569 $string =~ s/([\x00-\x08\x0B-\x1F])/sprintf "_x%04X_", ord($1)/eg;
  3         19  
639              
640 6212 100       18567 my $maybe_preserve_space = $string =~ /^\s|\s$/ ? ' xml:space="preserve"' : '';
641 6212         7307 my $node = qq{$string};
642              
643 6212         12903 return $node;
644             }
645              
646             sub escape_formula {
647 2     2 0 9 my ($string) = @_;
648              
649 2         9 $string =~ s/^=//;
650 2         88 $string =~ s/($entity_regex)/$entity{$1}/g;
651 2         13 return $string;
652             }
653              
654              
655             sub is_valid_date {
656 16     16 0 43 my ($named_captures, $n_days_ref) = @_;
657 16         27 my ($y, $m, $d) = @{$named_captures}{qw/y m d/};
  16         220  
658              
659             # years before 1900 can't be handled by Excel
660 16 100       88 return undef if $y < 1900;
661              
662             # convert the given date into a number of days since 1st January 1900.
663 15     15   508 my $return_status = try {$$n_days_ref = Delta_Days(1900, 1, 1, $y, $m, $d) + 1;
664 14         29 my $is_after_february_1900 = $$n_days_ref > 59;
665 14 100       38 $$n_days_ref += 1 if $is_after_february_1900; # because Excel wrongly treats 1900 as a leap year
666 14         60 1; # success
667 15         119 };
668             # no catch .. undef if failure (invalid date)
669            
670 15         297 return $return_status;
671              
672             # NOTE : invalid dates will be inserted as Excel strings
673             }
674              
675              
676             1;
677              
678             __END__