Excel-ValueWriter-XLSX

 view release on metacpan or  search on metacpan

lib/Excel/ValueWriter/XLSX.pm  view on Meta::CPAN

our $VERSION = '1.10';

#======================================================================
# GLOBALS
#======================================================================

my $DATE_STYLE = 1;                           # 0-based index into the <cellXfs> format for dates ..
                                              # .. defined in the styles() method

my $RX_SHEET_NAME = qr(^[^\\/?*\[\]]{1,31}$); # valid sheet names: <32 chars, no chars \/?*[] 
my $RX_TABLE_NAME = qr(^\w{3,}$);             # valid table names: >= 3 chars, no spaces

my %entity       = ( '<' => '&lt;', '>' => '&gt;', '&' => '&amp;' );
my $entity_regex = do {my $chars = join "", keys %entity; qr/[$chars]/};

#======================================================================
# SIGNATURES FOR CONTROLLING ARGS TO PUBLIC METHODS
#======================================================================

my $sig_for_new = Struict( # Struict = strict Struct .. not a typo!

  # date_regex : for identifying dates in data cells. Should capture into $+{d}, $+{m} and $+{y}.
  date_regex        => Regexp(-if_absent =>
                         qr[^(?: (?<d>\d\d?)    \. (?<m>\d\d?) \. (?<y>\d\d\d\d)  # dd.mm.yyyy
                               | (?<y>\d\d\d\d) -  (?<m>\d\d?) -  (?<d>\d\d?)     # yyyy-mm-dd
                               | (?<m>\d\d?)    /  (?<d>\d\d?) /  (?<y>\d\d\d\d)) # mm/dd/yyyy
                             $]x),

  # bool_regex : for identifying booleans in data cells. If true, should capture into $1
  bool_regex        => Regexp(-if_absent => qr[^(?:(TRUE)|FALSE)$]),

  # ZIP compression level
  compression_level => Int(-range => [1, 9], -if_absent => COMPRESSION_LEVEL_DEFAULT),
 )->meth_signature;


my $sig_for_add_sheet = List(
  String(-name => 'sheet_name', -regex => $RX_SHEET_NAME),
  String(-name => 'table_name', -regex => $RX_TABLE_NAME, -optional => 1),
  List  (-name => 'headers',    -all => String,           -optional => 1),
  One_of(-name => 'rows_maker', -options => [List,                                      # an array of rows, or
                                             Coderef,                                   # a row generator coderef, or
                                             Obj(-isa => 'DBI::st'),                    # a DBI statement, or
                                             Obj(-isa => 'DBIx::DataModel::Statement'), # a DBIx::DataModel statement
                                            ]),
  Struict(-name => 'options', -optional => 1, -fields => {
      cols => One_of(List(-all => Num),
                     List(-all => Struct(width => Num(-optional => 1),
                                         style => Int(-optional => 1),
                                         min   => Int(-optional => 1),
                                         max   => Int(-optional => 1))))
    }),
 )->meth_signature;

my $sig_for_add_sheets_from_database = List(-items => [Obj   (-isa     => 'DBI::db'),
                                                       String(-default => "S.")],
                                            -all   => String,
 )->meth_signature;

my $sig_for_add_defined_name = List(String(-name => "name"),
                                    String(-name => "formula"),
                                    String(-name => "comments", -optional => 1),
  )->meth_signature;



my $sig_for_save_as = One_of(String,
                             Whatever(-does => 'IO'),
  )->meth_signature;


  
#======================================================================
# CONSTRUCTOR
#======================================================================

sub new {
  my ($class, %self) = &$sig_for_new;

  # initial values for internal data structures (constructor args cannot initialize those)
  $self{sheets}                = []; # array of sheet names
  $self{tables}                = []; # array of table names
  $self{shared_string}         = {}; # ($string => $string_index)
  $self{n_strings_in_workbook} = 0;  # total nb of strings (including duplicates)
  $self{last_string_id}        = 0;  # index for the next shared string
  $self{defined_names}         = {}; # ($name => [$formula, $comment])

  # immediately open a Zip archive
  $self{zip} = Archive::Zip->new;

  # return the constructed object
  bless \%self, $class;
}


#======================================================================
# GATHERING DATA
#======================================================================


sub add_sheet {
  # the 3rd parameter ($headers) may be omitted -- so we insert an undef if necessary
  splice @_, 3, 0, undef if @_ < 5 or @_ == 5 && (ref $_[4] // '') eq 'HASH';

  # now we can parse the parameters
  my ($self, $sheet_name, $table_name, $headers, $rows_maker, $options) = &$sig_for_add_sheet;

  # register the sheet name 
  none {$sheet_name eq $_} @{$self->{sheets}}
    or croak "this workbook already has a sheet named '$sheet_name'";
  push @{$self->{sheets}}, $sheet_name;

  # iterator for generating rows
  my $row_iterator = $self->_build_row_iterator($rows_maker, \$headers);

  # build inner XML
  my ($xml, $last_row, $last_col) = $self->_build_rows($headers, $row_iterator, $table_name);

  # add XML preamble and close sheet data
  my $ref = $last_row >= 1 ? "A1:$last_col$last_row" : "A1"; # range of cells in sheet
  my $preamble = join "", 
    q{<?xml version="1.0" encoding="UTF-8" standalone="yes"?>},
    q{<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"},
              q{ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">},
    qq{<dimension ref="$ref"/>};
  $preamble .= $self->_xml_for_options($options) if $options;
  $preamble .= q{<sheetData>};
  substr $xml, 0, 0, $preamble;
  $xml .= q{</sheetData>};

  # if required, add the table corresponding to this sheet into the zip archive, and refer to it in XML
  my @table_rels;
  if ($table_name && $headers) {
    my $table_id = $self->_add_table($table_name, $last_col, $last_row, @$headers);
    push @table_rels, $table_id;
    $xml .= q{<tableParts count="1"><tablePart r:id="rId1"/></tableParts>};
  }

  # close the worksheet xml
  $xml .= q{</worksheet>};

  # insert the sheet and its rels into the zip archive
  my $sheet_id   = $self->n_sheets;
  my $sheet_file = "sheet$sheet_id.xml";
  $self->add_string_to_zip(encode_utf8($xml),                  "xl/worksheets/$sheet_file"           );
  $self->add_string_to_zip($self->worksheet_rels(@table_rels), "xl/worksheets/_rels/$sheet_file.rels");

lib/Excel/ValueWriter/XLSX.pm  view on Meta::CPAN

  }
  elsif (blessed $rows_maker && $rows_maker->isa('DBI::st')) {
    $rows_maker->{Executed}
      or croak '->add_sheet(..., $sth) : the statement handle must be executed (call the $sth->execute method)';
    $iterator  = sub { $rows_maker->fetchrow_arrayref};
    $$headers_ref //= $rows_maker->{NAME}; # see L<DBI>
  }
  elsif (blessed $rows_maker && $rows_maker->isa('DBIx::DataModel::Statement')) {
    DBIx::DataModel->VERSION >= 3.0
      or croak 'add_sheet(.., $statement) : requires DBIx::DataModel >= 3.0; your version is ', DBIx::DataModel->VERSION;
    $$headers_ref //= $rows_maker->sth->{NAME};
    $iterator  = sub {my $row = $rows_maker->next; return $row ? [@{$row}{@$$headers_ref}] : ()};
  }
  else {
    croak 'add_sheet() : missing or invalid last argument ($rows_maker)';
  }

  return $iterator;
}

sub _build_rows {
  my ($self, $headers, $row_iterator, $table_name) = @_;

  my $xml = "";

  # local copies for convenience
  my $date_regex = $self->{date_regex};
  my $bool_regex = $self->{bool_regex};

  # array of column references in A1 Excel notation
  my @col_letters = ('A'); # this array will be expanded on demand in the loop below

  # loop over rows and columns
  my $row_num = 0;
 ROW:
  for (my $row = $headers; $row; $row = $row_iterator->()) {
    $row_num++;
    my $last_col = @$row or next ROW;
    my @cells;

  COLUMN:
    foreach my $col (0 .. $last_col-1) {

      # if this column letter is not known yet, compute it using Perl's increment op on strings (so 'AA' comes after 'Z')
      my $col_letter = $col_letters[$col]
                   //= do {my $prev_letter = $col_letters[$col-1]; ++$prev_letter};

      # get the value; if the cell is empty, no need to write it into the XML
      my $val = $row->[$col];
      defined $val and length $val or next COLUMN;
      my $n_days; # in case we need to parse a date

      # choose XML attributes and inner value
      # NOTE : for perl, looks_like_number( "INFINITY") is TRUE! Hence the test $val !~ /^\pL/
      (                                              my $tag,  my $attrs,            $val)
      #                                                 ====   =========             ====
        = looks_like_number($val) && $val !~ /^\pL/    ? (v => ""                  , $val                           )
        : $date_regex && $val =~ $date_regex
                      && is_valid_date(\%+, \$n_days)  ? (v => qq{ s="$DATE_STYLE"}, $n_days                        )
        : $bool_regex && $val =~ $bool_regex           ? (v => qq{ t="b"}          , $1 ? 1 : 0                     )
        : $val =~ /^=/                                 ? (f => "",                   escape_formula($val)           )
        :                                                (v => qq{ t="s"}          , $self->_add_shared_string($val));

      # add the new XML cell
      my $cell = qq{<c r="$col_letter$row_num"$attrs><$tag>$val</$tag></c>};
      push @cells, $cell;
    }

    # generate the row XML and add it to the sheet
    my $row_xml = join "", qq{<row r="$row_num" spans="1:$last_col">}, @cells, qq{</row>};
    $xml .= $row_xml;
  }

  # if this sheet contains an Excel table, make sure there is at least one data row
  ++$row_num and $xml .= qq{<row r="$row_num" spans="1:1"></row>}
    if $table_name && $row_num == 1;

  return ($xml, $row_num, $col_letters[-1]);
}



sub _xml_for_options {
  my ($self, $options) = @_;

  # currently there is only one option 'cols'. Handled below in a separate sub for better clarity.
  return $self->_xml_for_cols_option($options->{cols});
}


sub _xml_for_cols_option {
  my ($self, $cols) = @_;

  my $xml = "<cols>";
  my $next_col_num = 1;
  foreach my $col (@$cols) {
    # build attributes for the node
    my %attrs = ref $col ? %$col : (width => $col); # cols => [6, ...] is just syntactic sugar for => [{width => 6}, ...]
    $attrs{$_} //= $next_col_num for qw/min max/;   # colrange to which this <col> specification applies
    $attrs{customWidth} //= 1 if $attrs{width};     # tells Excel that the width is not automatic

    # generate XML from attributes
    $xml .= join(" ", "<col", map {qq{$_="$attrs{$_}"}} keys %attrs) . "/>";

    # compute index of next column
    $next_col_num = $attrs{max} + 1;
  }
  $xml .= "</cols>";

  return $xml;
}


sub add_sheets_from_database {
  my ($self, $dbh, $sheet_prefix, @table_names) = &$sig_for_add_sheets_from_database;

  # in absence of table names, get them from the database metadata
  if (!@table_names) {
    my $tables = $dbh->table_info(undef, undef, undef, 'TABLE')->fetchall_arrayref({});
    @table_names = map {$_->{TABLE_NAME}} @$tables;
  }

lib/Excel/ValueWriter/XLSX.pm  view on Meta::CPAN

    my $sth = $dbh->prepare("select * from $table");
    $sth->execute;
    $self->add_sheet("$sheet_prefix$table", $table, $sth);
  }
}



sub _add_shared_string {
  my ($self, $string) = @_;

  # single quote before an initial equal sign is ignored (escaping the '=' like in Excel)
  $string =~ s/^'=/=/;

  # keep a global count of how many strings are in the workbook
  $self->{n_strings_in_workbook}++;

  # if that string was already stored, return its id, otherwise create a new id
  $self->{shared_strings}{$string} //= $self->{last_string_id}++;
}



sub _add_table {
  my ($self, $table_name, $last_col, $last_row, @col_names) = @_;

  # register this table
  none {$table_name eq $_} @{$self->{tables}}
    or croak "this workbook already has a table named '$table_name'";
  push @{$self->{tables}}, $table_name;
  my $table_id = $self->n_tables;

  # build column headers from first data row
  unshift @col_names, undef; # so that the first index is at 1, not 0
  my @columns = map {qq{<tableColumn id="$_" name="$col_names[$_]"/>}} 1 .. $#col_names;

  # Excel range of this table
  my $ref = "A1:$last_col$last_row";

  # assemble XML for the table
  my @xml = (
    qq{<?xml version="1.0" encoding="UTF-8" standalone="yes"?>},
    qq{<table xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"}.
         qq{ id="$table_id" displayName="$table_name" ref="$ref" totalsRowShown="0">},
    qq{<autoFilter ref="A1:D4"/>},
    qq{<tableColumns count="$#col_names">},
    @columns,
    qq{</tableColumns>},
    qq{<tableStyleInfo name="TableStyleMedium2" showFirstColumn="0" showLastColumn="0" showRowStripes="1" showColumnStripes="0"/>},
    qq{</table>},
   );

  # insert into the zip archive
  $self->add_string_to_zip(encode_utf8(join "", @xml), "xl/tables/table$table_id.xml");

  return $table_id;
}


sub add_defined_name {
  my ($self, $name, $formula, $comment) = &$sig_for_add_defined_name;

  not exists $self->{defined_names}{$name} or croak "add_defined_name(): name '$name' already in use";
  $self->{defined_names}{$name} = [$formula, $comment];
}


sub worksheet_rels {
  my ($self, $table_id) = @_;

  my @rels;
  push @rels, "officeDocument/2006/relationships/table" => "../tables/table$table_id.xml" if $table_id;
  return $self->relationships(@rels);
}


#======================================================================
# BUILDING THE ZIP CONTENTS
#======================================================================

sub save_as {
  my ($self, $target) = &$sig_for_save_as;

  # assemble all parts within the zip, except sheets and tables that were already added previously
  $self->add_string_to_zip($self->content_types,  "[Content_Types].xml"       );
  $self->add_string_to_zip($self->core,           "docProps/core.xml"         );
  $self->add_string_to_zip($self->app,            "docProps/app.xml"          );
  $self->add_string_to_zip($self->workbook,       "xl/workbook.xml"           );
  $self->add_string_to_zip($self->_rels,          "_rels/.rels"               );
  $self->add_string_to_zip($self->workbook_rels,  "xl/_rels/workbook.xml.rels");
  $self->add_string_to_zip($self->shared_strings, "xl/sharedStrings.xml"      );
  $self->add_string_to_zip($self->styles,         "xl/styles.xml"             );

  # write the Zip archive
  my $write_result = ref $target ? $self->{zip}->writeToFileHandle($target) : $self->{zip}->writeToFileNamed($target);
  $write_result == AZ_OK
    or croak "could not save Zip archive into " . (ref($target) || $target);
}


sub add_string_to_zip {
  my ($self, $content, $name) = @_;

  $self->{zip}->addString($content, $name, $self->{compression_level});
}



sub _rels {
  my ($self) = @_;

  return $self->relationships("officeDocument/2006/relationships/extended-properties" => "docProps/app.xml",
                              "package/2006/relationships/metadata/core-properties"   => "docProps/core.xml",
                              "officeDocument/2006/relationships/officeDocument"      => "xl/workbook.xml");
}

sub workbook_rels {
  my ($self) = @_;

  my @rels = map {("officeDocument/2006/relationships/worksheet"     => "worksheets/sheet$_.xml")}
                 1 .. $self->n_sheets;
  push @rels,      "officeDocument/2006/relationships/sharedStrings" => "sharedStrings.xml",
                   "officeDocument/2006/relationships/styles"        => "styles.xml";

lib/Excel/ValueWriter/XLSX.pm  view on Meta::CPAN


  # build a "rel" file from a list of relationships
  my @xml = (
    qq{<?xml version="1.0" encoding="UTF-8" standalone="yes"?>},
    qq{<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">},
   );

  my $id = 1;
  while (my ($type, $target) = splice(@rels, 0, 2)) {
    push @xml, qq{<Relationship Id="rId$id" Type="http://schemas.openxmlformats.org/$type" Target="$target"/>};
    $id++;
  }

  push @xml, qq{</Relationships>};

  return join "", @xml;
}


sub n_sheets {
  my ($self) = @_;
  return scalar @{$self->{sheets}};
}

sub n_tables {
  my ($self) = @_;
  return scalar @{$self->{tables}};
}


#======================================================================
# UTILITY ROUTINES
#======================================================================


sub si_node { # build XML node for a single shared string
  my ($string) = @_;

  # escape XML entities
  $string =~ s/($entity_regex)/$entity{$1}/g;


  # Excel escapes control characters with _xHHHH_ and also escapes any
  # literal strings of that type by encoding the leading underscore. So
  # "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_.
  # The following substitutions deal with those cases.
  # This code is borrowed from Excel::Writer::XLSX::Package::SharedStrings -- thank you, John McNamara

  # Escape the escape.
  $string =~ s/(_x[0-9a-fA-F]{4}_)/_x005F$1/g;

  # Convert control character to the _xHHHH_ escape.
  $string =~ s/([\x00-\x08\x0B-\x1F])/sprintf "_x%04X_", ord($1)/eg;

  my $maybe_preserve_space = $string =~ /^\s|\s$/ ? ' xml:space="preserve"' : '';
  my $node = qq{<si><t$maybe_preserve_space>$string</t></si>};

  return $node;
}

sub escape_formula {
  my ($string) = @_;

  $string =~ s/^=//;
  $string =~ s/($entity_regex)/$entity{$1}/g;
  return $string;
}


sub is_valid_date {
  my ($named_captures, $n_days_ref) = @_;
  my ($y, $m, $d) = @{$named_captures}{qw/y m d/};

  # years before 1900 can't be handled by Excel
  return undef if $y < 1900;

  # convert the given date into a number of days since 1st January 1900.
  my $return_status = try   {$$n_days_ref = Delta_Days(1900, 1, 1, $y, $m, $d) + 1;
                             my $is_after_february_1900 = $$n_days_ref > 59;
                             $$n_days_ref += 1 if $is_after_february_1900; # because Excel wrongly treats 1900 as a leap year
                             1; # success
                            };
                             # no catch .. undef if failure (invalid date)
 
  return $return_status;

  # NOTE : invalid dates will be inserted as Excel strings
}


1;

__END__

=encoding utf-8

=head1 NAME

Excel::ValueWriter::XLSX - generating data-only Excel workbooks in XLSX format, fast

=head1 SYNOPSIS

  my $writer = Excel::ValueWriter::XLSX->new;
  $writer->add_sheet($sheet_name1, $table_name1, [qw/a b tot/], [[1, 2, '=[a]+[b]'],
                                                                 [3, 4],
                                                                 ['TRUE', 'FALSE'],
                                                                ]);
  $writer->add_sheet($sheet_name2, $table_name2, \@headers, sub {...});
  $writer->add_sheet($sheet_name3, $table_name3, $sth);       # DBI statement handle
  $writer->add_sheet($sheet_name4, $table_name4, $statement); # DBIx::DataModel::Statement object
  $writer->add_sheets_from_database($dbh);
  $writer->add_defined_name($name, $formula, $comment);
  $writer->save_as($filename);
  
  $writer = Excel::ValueWriter::XLSX->new(bool_regex => qr[^(?:(VRAI)|FAUX)$]);
  $writer->add_sheet($sheet_name1, $table_name1, [qw/a b/], [['I like Perl:', 'VRAI']]);


=head1 DESCRIPTION

The present module is aimed at fast and cost-effective
production of "data-only" Excel workbooks, containing nothing but plain values
and formulas.

CPU and memory usage are much lower than with the well-known L<Excel::Writer::XLSX>;
however the set of features is also much more restricted : there is no support
for formats, colors, figures or other fancy Excel features.

Such workbooks with plain data are useful for example :

=over 

=item *

in architectures where Excel is used merely as a local database, for
example in connection with a Power Pivot architecture (for that purpose,
see also the companion module L<Excel::PowerPivot::Utils>);

=item *

for fine tuning of the ZIP compression level to be applied
to the generated C<.xlsx> file;

=item *

for generating files with large amounts of data (this was the original
motivation for writing this module, because
L<Excel::Writer::XLSX> tends to become very slow on files with large
numbers of rows).

=back


=head1 METHODS

=head2 new

  my $writer = Excel::ValueWriter::XLSX->new(%options);

Constructor for a new writer object. Options are :

=over

=item date_regex

A compiled regular expression for detecting data cells that contain dates.
The default implementation recognizes dates in C<dd.mm.yyyy>, C<yyyy-mm-dd>
and C<mm/dd/yyyy> formats. User-supplied regular expressions should use
named captures so that the day, month and year values can be found respectively
in C<< $+{d} >>, C<< $+{m} >> and C<< $+{y} >>. If this parameter receives C<undef>,
no date handling will be performed.

=item bool_regex

A compiled regular expression for detecting data cells that contain boolean values.
The default implementation recognizes uppercase strings 'TRUE' or 'FALSE' as booleans.
User-supplied regular expressions should put the word corresponding to 'TRUE' within
parenthesis so that the content is captured in C<$1>. Here is an example for french :

  $writer = Excel::ValueWriter::XLSX->new(bool_regex => qr[^(?:(VRAI)|FAUX)$]);

If this parameter receives C<undef>, no bool handling will be performed.

lib/Excel/ValueWriter/XLSX.pm  view on Meta::CPAN


a reference to a 2-dimensional array of values

=item *

a reference to a callback function that will return a new row at each call, in the
form of a 1-dimensional array reference. An empty return from the callback
function signals the end of data (but intermediate empty rows may be returned
as C<< [] >>). Callback functions should typically be I<closures> over a lexical
variable used to decide when the last row has been met. Here is an example of a
callback function used to feed a sheet with 500 lines of 300 columns of random numbers:

  my @headers_for_rand = map {"h$_"} 1 .. 300;
  my $random_rows = do {my $count = 500; sub {$count-- > 0 ? [map {rand()} 1 .. 300] : undef}};
  $writer->add_sheet(RAND_SHEET => rand => \@headers_for_rand, $random_rows);

=item *

an executed L<DBI> statement handle

=item *

a L<DBIx::DataModel::Statement> object (version 3.0 or greater)

=back

=item *

C<< \%options >> is an optional reference to a hash of additional options.
Currently the only option is C<cols>, which points to an arrayref of column specifications.
The arrayref may contain :

=over

=item *

a list of numbers specifying the width of successive columns, like for example :

  $writer->add_sheet(S_sales => sales => [qw/date product_id descr price/], $rows, {cols => [12, 8, 20, 8]);

Column widths in Excel are expressed in "character units", i.e. a requested width of 8 should
be just wide enough to display the string '00000000' (8 zero characters displayed with the normal font).

=item *

a list of hashrefs specifying column attributes. The C<width> attribute corresponds to column widths,
as explained above. The C<style> attribute is a numeric index into the list of styles defined within
the workbook -- except that currently this module only supports one single style for dates, so this
addition has no practical use at the moment.

=back


=back

Cells within a row must contain scalar values. Values that look like numbers are treated
as numbers. String values that match the C<date_regex> are converted into numbers and
displayed through a date format, but only if the date is valid and is above 1900 January 1st --
otherwise it is treated as a string, like in Excel.
String values that start with an initial '=' are treated
as formulas; but like in Excel, if you want a plain string that starts with a '=', put a single
quote just before the '=' -- that single quote will be removed from the string.
Everything else is treated as a string. Strings are shared at the workbook level
(hence a string that appears several times in the input data will be stored
only once within the workbook).

=head2 add_sheets_from_database

  $writer->add_sheets_from_database($dbh, $sheet_prefix, @table_names);

Gets data from database tables and adds them as sheets into the Excel workbook.
Arguments are :

=over

=item C<$dbh>

An active L<DBI> database handle

=item C<$sheet_prefix>

A string that will be prepended at the beginning of each worksheet name,
so that they are different from names of Excel tables.
The default is 'S.'.

=item C<@table_names>

The list of tables to be read from the database.
If empty, table names are retrieved automatically from the database
through the L<DBI/table_info> method.

=back

=head2 add_defined_name

  $writer->add_defined_name($name, $formula, $comment);

Adds a "defined name" to the workbook. Defined names can be used
in any formula within the workbook, and will be replaced by
the corresponding content.

=over

=item *

C<$name> is mandatory and must be unique

=item *

C<$formula> is mandatory and will be interpreted by Excel like a formula.
References to ranges should include the sheet name and use absolute coordinates;
for example for concatenating two cells in sheet 's1', the formula is :

  $writer->add_defined_name(cells_1_and_2 => q{'s1'!$A$1&'s1'!$A$2});

If the intended content is just a constant string, it must be enclosed in double quotes, i.e.

  $writer->add_defined_name(my_string => q{"my_constant_value"});

=item *

C<$comment> is optional; it will appear when users consult the 
L<name manager|https://support.microsoft.com/en-us/office/use-the-name-manager-in-excel-4d8c4c2b-9f7d-44e3-a3b4-9f61bd5c64e4> in the Formulas tab.

=back


=head2 save_as

  $writer->save_as($target);

Writes the workbook contents into the specified C<$target>, which can be either
a filename or filehandle opened for writing.


=head1 ARCHITECTURAL NOTES

=head2 Object-orientedness

Although I'm a big fan of L<Moose> and its variants, the present module is implemented
in POPO (Plain Old Perl Object) : since the aim is to maximize cost-effectiveness, and since
the object model is extremely simple, there was no ground for using a sophisticated object system.

=head2 Benchmarks

I did a couple of measurements that demonstrate the effectiveness of this
module on large datasets; but the results cannot be included into this distribution yet,
because the tests are not sufficiently formalized and they are based on
on data that is not public.

=head2 To do

  - options for workbook properties : author, etc.
  - support for 1904 date schema
  - reproducible benchmarks

=head1 SEE ALSO

L<Excel::Writer::XLSX>, L<Excel::PowerPivot::Utils>.

=head1 AUTHOR

Laurent Dami, E<lt>dami at cpan.orgE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright 2022-2025 by Laurent Dami.

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.



( run in 0.580 second using v1.01-cache-2.11-cpan-39bf76dae61 )