File Coverage

blib/lib/Spreadsheet/ReadGnumeric.pm
Criterion Covered Total %
statement 244 260 93.8
branch 91 110 82.7
condition 31 35 88.5
subroutine 40 41 97.5
pod 5 5 100.0
total 411 451 91.1


line stmt bran cond sub pod time code
1             # -*- mode: perl; -*-
2             #
3             # Spreadsheet reader for Gnumeric format that returns a
4             # Spreadsheet::Read-compatible data structure.
5             #
6             # Documentation below "__END__".
7             #
8             # [created. -- rgr, 26-Dec-22.]
9             #
10              
11             package Spreadsheet::ReadGnumeric;
12              
13 4     4   230679 use 5.010;
  4         37  
14              
15 4     4   17 use strict;
  4         7  
  4         165  
16 4     4   19 use warnings;
  4         6  
  4         129  
17              
18 4     4   1635 use XML::Parser::Lite;
  4         13340  
  4         126  
19 4     4   1446 use Spreadsheet::Gnumeric::StyleRegion;
  4         8  
  4         136  
20              
21             our $VERSION = '0.4';
22              
23 4     4   21 use parent qw(Spreadsheet::Gnumeric::Base);
  4         7  
  4         17  
24              
25             BEGIN {
26 4     4   262 Spreadsheet::ReadGnumeric->define_instance_accessors
27             (# User options to control parsing.
28             qw(gzipped_p cells rc attr merge),
29             qw(process_formulas minimal_attributes convert_colors),
30             # Intermediate data structures for sheet parsing.
31             qw(sheet style_regions style_attributes formulas),
32             # XML parsing internals.
33             qw(current_elt current_attrs chars namespaces element_stack));
34             }
35              
36             sub new {
37 13     13 1 47025 my ($class, @options) = @_;
38              
39 13         88 my $self = $class->SUPER::new(@options);
40 13   100     65 $self->{_cells} //= 1;
41 13   100     48 $self->{_rc} //= 1;
42 13   100     66 $self->{_convert_colors} //= 1;
43 13         50 return $self;
44             }
45              
46             sub sheets {
47             # Read-only slot. Make sure the sheets slot is initialized.
48 83     83 1 163 my ($self) = @_;
49              
50 83         150 my $sheets = $self->{_sheets};
51 83 100       170 unless ($sheets) {
52             # [I'm afraid this is just cargo-culting here. -- rgr, 27-Dec-22.]
53 13         61 my $parser_data = { parser => __PACKAGE__,
54             type => 'gnumeric',
55             version => $VERSION };
56 13         77 my $attrs = { parsers => [ $parser_data ],
57             %$parser_data,
58             error => undef };
59 13         28 $sheets = [ $attrs ];
60 13         28 $self->{_sheets} = $sheets;
61             }
62 83         1810 return $sheets;
63             }
64              
65             ### XML parsing support.
66              
67             # This is because XML::Parser::Lite callbacks are called with the Expat object
68             # and not us, so we must bind $Self dynamically around the parsing operation.
69 4     4   25 use vars qw($Self);
  4         6  
  4         12347  
70              
71             sub _context_string {
72 0     0   0 my ($self) = @_;
73              
74 0         0 my $stack = $self->element_stack;
75 0 0       0 return $self->current_elt
76             unless $stack;
77 0         0 return join(' ', (map { $_->[0]; } @$stack), $self->current_elt);
  0         0  
78             }
79              
80             sub _decode_xlmns_name {
81             # Figure out whether we have a Gnumeric element name.
82 13154     13154   19530 my ($self, $elt_name) = @_;
83              
84 13154         49118 my ($ns_prefix, $base_elt_name) = $elt_name =~ /^([^:]+):([^:]+)$/;
85 13154 50       25437 if ($ns_prefix) {
86             # See if the prefix is the one we want.
87 13154         24398 my $url = $self->namespaces->{$ns_prefix};
88 13154 100 66     41430 if ($url && $url eq "http://www.gnumeric.org/v10.dtd") {
89             # Belongs to the Gnumeric schema.
90 12930         28365 return ($base_elt_name, 1);
91             }
92             else {
93             # It's something else.
94 224         446 return ($elt_name, 0);
95             }
96             }
97             else {
98             # Assume unqualified names belong to Gnumeric (even though we've never
99             # seen any).
100 0         0 return ($elt_name, 1);
101             }
102             }
103              
104             sub _handle_start {
105 6577     6577   317015 my ($expat, $elt, @attrs) = @_;
106 6577         25035 my $attrs = { @attrs };
107              
108             # Establish the new namespace scope. We do this first, because it may
109             # define the prefix used on this element.
110 6577   100     13853 my $old_ns_scope = $Self->namespaces || { };
111 6577         20386 my $new_ns_scope = { %$old_ns_scope };
112 6577 100       17863 if (grep { /^xmlns:(.*)$/ } keys(%$attrs)) {
  22716         39258  
113             # Copy so as not to clobber the outer scope.
114 26         77 $new_ns_scope = { %$old_ns_scope };
115 26         79 for my $attr (keys(%$attrs)) {
116 117 100       305 if ($attr =~ /^xmlns:(.*)$/) {
117 91         163 my $ns_prefix = $1;
118 91         250 $new_ns_scope->{$ns_prefix} = $attrs->{$attr};
119             }
120             }
121             }
122 6577         15933 $Self->namespaces($new_ns_scope);
123              
124             # Stack the outer context.
125 6577   100     11014 my $stack = $Self->element_stack || [ ];
126 6577 100       12068 push(@$stack, [ $Self->current_elt, $Self->current_attrs,
127             $Self->chars, $old_ns_scope ])
128             if $Self->current_elt;
129 6577         15826 $Self->element_stack($stack);
130              
131             # Install the new element context.
132 6577         10694 my ($decoded_name) = $Self->_decode_xlmns_name($elt);
133 6577         15336 $Self->current_elt($decoded_name);
134 6577         13563 $Self->current_attrs($attrs);
135 6577         11457 $Self->chars('');
136             }
137              
138             sub _handle_char {
139             # Just collect them in our "chars" slot.
140 11339     11339   387776 my ($expat, $chars) = @_;
141              
142 11339         14530 if (0) {
143             warn("handle_char: '$chars' in ", $Self->_context_string, "\n")
144             unless $chars =~ /^\s*$/;
145             }
146 11339         36400 $Self->{_chars} .= $chars;
147             }
148              
149             sub _handle_end {
150 6577     6577   152226 my ($expat, $raw_elt_name) = @_;
151              
152             # Process the completed element.
153 6577         11575 my ($elt_name, $gnumeric_p) = $Self->_decode_xlmns_name($raw_elt_name);
154 6577 100       13023 if ($gnumeric_p) {
155 6465         12573 my $method = "_process_${elt_name}_elt";
156 6465 100       23692 $Self->$method($Self->chars, %{$Self->current_attrs})
  4275         7573  
157             if $Self->can($method);
158             }
159              
160             # Restore the outer element context.
161 6577         13593 my $stack = $Self->element_stack;
162             return
163 6577 100       10930 unless @$stack;
164 6564         7536 my ($elt, $attrs, $chars, $old_ns_scope) = @{pop(@$stack)};
  6564         12036  
165 6564         16865 $Self->current_elt($elt);
166 6564         13951 $Self->current_attrs($attrs);
167 6564         14929 $Self->chars($chars);
168 6564         10658 $Self->namespaces($old_ns_scope);
169             }
170              
171             ### Utility routines.
172              
173             sub _decode_alpha {
174             # Note that this is one-based, so "A" is 1, "Z" is 26, and "AA" is 27. But
175             # there is no consistent zero digit, so this is not really base 26; if it
176             # were, "A" should be zero and "Z" should overflow to "BA".
177 144     144   283 my ($alpha) = @_;
178              
179 144 100       218 if (length($alpha) == 0) {
180 54         193 return 0;
181             }
182             else {
183 90         176 return (26 * _decode_alpha(substr($alpha, 0, -1))
184             + ord(lc(substr($alpha, -1))) - ord('a') + 1)
185             }
186             }
187              
188             sub _decode_cell_name {
189 22     22   33 my ($cell_name) = @_;
190              
191 22         74 my ($alpha, $digits) = $cell_name =~ /^([a-zA-Z]+)(\d+)$/;
192             return
193 22 50       42 unless $alpha;
194 22         38 return (_decode_alpha($alpha), 0 + $digits);
195             }
196              
197             sub _encode_cell_name {
198             # Note that $value is one-based, so 1 corresponds to "A". See the note
199             # under _decode_alpha.
200 1322     1322   15060 my ($value) = @_;
201              
202 1322 100       4966 return ($value <= 26
203             ? chr(ord('A') + $value - 1)
204             : (_encode_cell_name(int(($value - 1) / 26))
205             . _encode_cell_name(1 + ($value - 1) % 26)));
206             }
207              
208             sub _tokenize_formula {
209             # This only cares about cell names; everything else is left as strings.
210             # But we have to tokenize well enough that we don't mistake the "E9" in the
211             # number "9E9" for a cell name, which is why we must distinguish numbers.
212             # The result is an arrayref of two kinds of elements: Cell names are
213             # turned into two-element arrayrefs of _decode_cell_name indices, and
214             # everything else (including whitespace) remains a string.
215 3     3   84 my ($formula) = @_;
216              
217 3         7 my $parsed = [ ];
218 3         11 while (length($formula)) {
219 29 50       105 if ($formula =~ /^([-+]?[\d.]+([-+]?[eE]\d+)?)/) {
    100          
220             # This includes some malformed numeric constants, but we don't have
221             # to care; there shouldn't be any such in saved spreadsheets.
222 0         0 my $number = $1;
223 0         0 push(@$parsed, $number);
224 0         0 $formula = substr($formula, length($number));
225             }
226             elsif ($formula =~/^(\w+\d+)/) {
227 14         25 my $name = $1;
228 14         25 push(@$parsed, [ _decode_cell_name($name) ]);
229 14         31 $formula = substr($formula, length($name));
230             }
231             else {
232 15         36 my ($stuff) = $formula =~ /^([^\w\d]+)/;
233 15         28 push(@$parsed, $stuff);
234 15         37 $formula = substr($formula, length($stuff));
235             }
236             }
237 3         6 return $parsed;
238             }
239              
240             sub _untokenize_formula {
241             # Given a parsed formula returned by _tokenize_formula above plus
242             # column and row offsets, construct a new formula string and return it.
243 7     7   1399 my ($parsed, $delta_col, $delta_row) = @_;
244              
245             join('', map {
246 7 100       16 if (ref($_)) {
  67         93  
247 32         43 my ($col, $row) = @$_;
248 32         48 _encode_cell_name($col + $delta_col) . ($row + $delta_row);
249             }
250             else {
251 35         65 $_;
252             }
253             } @$parsed);
254             }
255              
256             ### Spreadsheet parsing
257              
258             sub _parse_stream {
259             # Create a new XML::Parser::Lite instance, use it to drive the parsing of
260             # $xml_stream (which we assume is uncompressed), and return the resulting
261             # spreadsheet object.
262 13     13   36 my ($self, $xml_stream) = @_;
263              
264 13         144 my $parser = XML::Parser::Lite->new
265             (Style => 'Stream',
266             Handlers => { Start => \&_handle_start,
267             End => \&_handle_end,
268             Char => \&_handle_char });
269 13         1014 local $Self = $self;
270 13         684 $parser->parse(join('', <$xml_stream>));
271 13         2177 return $self->sheets;
272             }
273              
274             sub stream_gzipped_p {
275 13     13 1 40 my ($self, $stream, $file) = @_;
276              
277             # The point of the gzipped_p slot is to allow callers to suppress the gzip
278             # test if the stream is not seekable.
279 13         51 my $gzipped_p = $self->gzipped_p;
280 13 50       45 if (! defined($gzipped_p)) {
281 13 50       467 read($stream, my $block, 2) or do {
282 0         0 my $file_msg = 'from stream';
283 0         0 $file_msg = " from '$file'";
284 0         0 die "$self: Failed to read opening bytes$file_msg: $!";
285             };
286             # Test if gzipped (/usr/share/misc/magic).
287 13         49 $gzipped_p = $block eq "\037\213";
288 13         123 seek($stream, 0, 0);
289             }
290 13         170 return $gzipped_p;
291             }
292              
293             sub parse {
294 13     13 1 79 my ($self, $input) = @_;
295              
296 13         24 my $stream;
297 13 100       71 if (ref($input)) {
    100          
298             # Assume it's a stream.
299 2         4 $stream = $input;
300             }
301             elsif ($input =~ m/\A(\037\213|<\?xml)/) {
302             # $input is literal content, compressed and/or XML.
303 2     1   53 open($stream, '<', \$input);
  1         8  
  1         2  
  1         6  
304             }
305             else {
306 9 50       424 open($stream, '<', $input)
307             or die "$self: Failed to open '$input': $!";
308             }
309 13 100   3   880 binmode($stream, ':gzip')
  3 100       17  
  3         5  
  3         19  
310             if $self->stream_gzipped_p($stream, ref($input) ? () : $input);
311 13     3   4540 binmode($stream, ':encoding(UTF-8)');
  3         16  
  3         7  
  3         13  
312 13         29977 return $self->_parse_stream($stream);
313             }
314              
315             sub _process_Name_elt {
316             # Record the sheet name.
317 171     171   292 my ($self, $name) = @_;
318              
319             # Find the enclosing element, which needs to be "Sheet".
320 171         300 my $stack = $self->element_stack;
321             return
322 171 100       520 unless $stack->[@$stack-1][0] eq 'Sheet';
323 35         80 my $sheets = $self->sheets;
324 35         128 $sheets->[0]{sheet}{$name} = @$sheets;
325 35         127 $self->{_sheet}{label} = $name;
326             }
327              
328             sub _process_MaxCol_elt {
329 35     35   88 my ($self, $maxcol) = @_;
330              
331 35         81 $self->{_sheet}{mincol} = 1;
332 35         150 $self->{_sheet}{maxcol} = 1 + $maxcol;
333             }
334              
335             sub _process_MaxRow_elt {
336 35     35   121 my ($self, $maxrow) = @_;
337              
338             return
339 35 50       105 unless defined($maxrow);
340 35         80 $self->{_sheet}{minrow} = 1;
341 35         87 $self->{_sheet}{maxrow} = 1 + $maxrow;
342             }
343              
344             sub _process_Merge_elt {
345             # We don't care about MergedRegions, as that's just a container.
346 4     4   8 my ($self, $text) = @_;
347              
348 4         20 push(@{$self->{_sheet}{merged}},
349 4 50       9 [ map { _decode_cell_name($_) } split(':', $text) ])
  8         17  
350             if $self->attr;
351             }
352              
353             sub _process_Cell_elt {
354 2348     2348   7061 my ($self, $text, %keys) = @_;
355              
356             # Both $row and $col are zero-based; the cell matrix is one-based.
357 2348         4323 my ($row, $col) = ($keys{Row}, $keys{Col});
358              
359             # Deal with ExprID references.
360 2348 100       4532 if (my $id = $keys{ExprID}) {
361 720 100       1385 if (! $self->process_formulas) {
    100          
    50          
362             # The caller doesn't care.
363             }
364             elsif ($text) {
365             # Defining this formula.
366 4         14 $self->{_formulas}[$id] = [ $col, $row, $text, undef ];
367             }
368             elsif (my $expr = $self->{_formulas}[$id]) {
369             # Reference to a previously defined formula.
370 6         13 my ($def_col, $def_row, $def_text, $parsed) = @$expr;
371 6 100       16 $parsed = $expr->[3] = _tokenize_formula($def_text)
372             unless $parsed;
373 6         19 $text = _untokenize_formula($parsed,
374             $col - $def_col, $row - $def_row);
375             }
376             else {
377             # The spreadsheet save code should ensure this never happens.
378 0         0 my $cell = _encode_cell_name($col + 1) . ($row + 1);
379 0         0 my $label = $self->{_sheet}{label};
380 0         0 warn("Undefined ExprID '$id' in $cell of ",
381             "sheet '$label'; ignoring.\n");
382             }
383             }
384              
385             # Ignore empty cells.
386             return
387 2348 100       4444 unless $text;
388 1656 100       3200 $self->{_sheet}{cell}[$col + 1][$row + 1] = $text
389             if $self->rc;
390 1656 100       3174 $self->{_sheet}{_encode_cell_name($col + 1) . ($row + 1)} = $text
391             if $self->cells;
392 1656 100       3652 $self->{_sheet}{attr}[$col + 1][$row + 1]
393             = $self->find_style_for_cell($col, $row)
394             if $self->attr;
395             }
396              
397             sub _process_Sheet_elt {
398             # Add $self->sheet to $self->sheets.
399 35     35   288 my ($self, $text, %keys) = @_;
400              
401 35         127 my $sheets = $self->sheets;
402 35         83 my $attrs = $sheets->[0];
403 35         111 my $indx = $attrs->{sheets} = @$sheets;
404 35         110 my $sheet = $self->sheet;
405             $self->{_sheet}{cell}[0] = [ ] # for consistency.
406 35 100       134 if $self->{_sheet}{cell};
407 35         89 push(@$sheets, $sheet);
408 35         69 $sheet->{indx} = $indx;
409 35         84 my $attr = $self->attr;
410 35 100       78 if ($attr) {
411 8 100       28 $sheet->{style_regions} = $self->style_regions
412             if $attr eq 'keep';
413 8         28 $self->style_regions([ ]);
414             # Distribute non-minimal attributes.
415 8 50       29 unless ($self->minimal_attributes) {
416 8         28 for my $col (1 .. $sheet->{maxcol}) {
417 1544   50     2628 for my $row (1 .. $sheet->{maxrow} || 0) {
418 28704   66     62629 $self->{_sheet}{attr}[$col][$row]
419             ||= $self->find_style_for_cell($col - 1, $row - 1);
420             }
421             }
422             }
423             # Distribute merging information.
424 8         18 my $merged = $sheet->{merged};
425 8 100       24 if ($merged) {
426 2         4 my $attrs = $sheet->{attr};
427 2         7 my $merge_p = $self->merge;
428 2         5 for my $merge (@$merged) {
429 4         8 my ($col1, $row1, $col2, $row2) = @$merge;
430 4         8 my $base_cell_name = _encode_cell_name($col1) . $row1;
431 4 100       10 my $merge_flag = $merge_p ? $base_cell_name : 1;
432             my $base_cell_contents = ($self->rc
433             ? $sheet->{cell}[$col1][$row1]
434 4 50       7 : $sheet->{$base_cell_name});
435 4         8 for my $col ($col1 .. $col2) {
436 6         10 for my $row ($row1 .. $row2) {
437 12   100     32 my $cell_attrs
438             = ($attrs->[$col][$row]
439             || $self->find_style_for_cell($col - 1,
440             $row - 1)
441             || { });
442             # Copy the hash so we don't accidentally mark other
443             # cells as merged due to shared structure.
444 12         50 $cell_attrs = { %$cell_attrs };
445 12         27 $cell_attrs->{merged} = $merge_flag;
446 12         15 $attrs->[$col][$row] = $cell_attrs;
447             # When $merge_p, also copy cell contents.
448 12 100       22 if ($merge_p) {
449 6 50       13 $sheet->{cell}[$col][$row] = $base_cell_contents
450             if $self->rc;
451 6         20 my $cell_name = _encode_cell_name($col) . $row;
452 6 50       14 $sheet->{$cell_name} = $base_cell_contents
453             if $self->cells;
454             }
455             }
456             }
457             }
458             }
459             }
460 35         101 $self->sheet({ });
461             }
462              
463             ## Parsing cell style attributes
464              
465             sub _convert_identity {
466             # We assume this is 0 or 1.
467 1665     1665   2132 my ($value) = @_;
468              
469 1665         2361 return $value;
470             }
471              
472             sub _convert_align {
473             # This is a string which we truncate and downcase -- and hope the caller
474             # can figure it out, since Gnumeric seems to use non-standard values.
475 216     216   279 my ($value) = @_;
476              
477 216 50       992 return $value =~ /^GNM_.ALIGN_(.*)$/ ? lc($1) : lc($value);
478             }
479              
480             sub _convert_color {
481 171     171   236 my ($color) = @_;
482              
483             my $convert_primary = sub {
484             # Pad and/or truncate the value.
485 513     513   669 my ($value) = @_;
486              
487 513         689 my $len = length($value);
488 513 100       772 if ($len <= 2) {
    50          
489             # Less than #xFF we round to zero.
490 342         912 return '00';
491             }
492             elsif ($len == 3) {
493 0         0 return '0' . substr($value, 0, 1);
494             }
495             else {
496 171         519 return substr($value, 0, 2);
497             }
498 171         509 };
499              
500 171         514 return join('', '#', map { $convert_primary->($_); } split(':', $color));
  513         784  
501             }
502              
503             my %style_conversion_map
504             = (Back => [ bgcolor => \&_convert_color, 1 ],
505             Bold => [ bold => \&_convert_identity ],
506             # Font is not an attribute.
507             Fore => [ fgcolor => \&_convert_color, 1 ],
508             Format => [ format => \&_convert_identity ],
509             HAlign => [ halign => \&_convert_align ],
510             Hidden => [ hidden => \&_convert_identity ],
511             Indent => [ indent => \&_convert_identity ],
512             Italic => [ italic => \&_convert_identity ],
513             Locked => [ locked => \&_convert_identity ],
514             PatternColor => [ pattern_color => \&_convert_color, 1 ],
515             Rotation => [ rotation => \&_convert_identity ],
516             Script => [ script => \&_convert_identity ],
517             Shade => [ shade => \&_convert_identity ],
518             ShrinkToFit => [ shrink_to_fit => \&_convert_identity ],
519             Unit => [ size => \&_convert_identity ],
520             StrikeThrough => [ strike_through => \&_convert_identity ],
521             Underline => [ uline => \&_convert_identity ],
522             VAlign => [ valign => \&_convert_align ],
523             WrapText => [ wrap => \&_convert_identity ]);
524              
525             sub _convert_attributes {
526             # It is a useful coincidence that the cell metadata Spreadsheet::Read
527             # calls attributes are stored in XML attributes by Gnumeric. So we convert
528             # Gnumeric cell attributes to Spreadsheet::Read cell attributes.
529 216     216   335 my ($self, $cell_attributes, $gnumeric_attributes) = @_;
530              
531 216         389 my $convert_colors = $self->convert_colors;
532 216         836 for my $name (keys(%$gnumeric_attributes)) {
533 2052         3141 my $value = $gnumeric_attributes->{$name};
534 2052         2808 my $conversion = $style_conversion_map{$name};
535 2052 50       3626 my ($converted_name, $converter, $color_p)
536             = ($conversion ? @$conversion : (lc($name), \&_convert_identity));
537 2052 100 100     3607 $converter = \&_convert_identity
538             if $color_p && ! $convert_colors;
539 2052         2685 my $converted_value = $converter->($value);
540 2052         4256 $cell_attributes->{$converted_name} = $converted_value;
541             }
542             }
543              
544             sub _process_Font_elt {
545             # Process a font definition. This is contained inside
547             # fills it out.
548 549     549   2408 my ($self, $font_name, %keys) = @_;
549              
550             return
551 549 100       1163 unless $self->attr;
552 108         263 my $style_attributes = { font => $font_name };
553 108         276 $self->_convert_attributes($style_attributes, \%keys);
554 108         288 $self->style_attributes($style_attributes);
555             }
556              
557             sub _process_Style_elt {
558             # Process style attributes.
559 549     549   3488 my ($self, $text, %keys) = @_;
560              
561             return
562 549 100       1083 unless $self->attr;
563 108 50       202 my $style_attributes = $self->style_attributes or die;
564 108         233 $self->_convert_attributes($style_attributes, \%keys);
565             }
566              
567             sub find_style_for_cell {
568             # Assuming $self->attr is true, find the style region for ($col, $row) and
569             # return its attribute hash.
570 28712     28712 1 38965 my ($self, $col, $row) = @_;
571              
572 28712         30165 my $found;
573 28712         29534 for my $style_region (@{$self->{_style_regions}}) {
  28712         39976  
574 15746 100 100     25001 if ($style_region->start_col <= $col
      100        
      100        
575             && $col <= $style_region->end_col
576             && $style_region->start_row <= $row
577             && $row <= $style_region->end_row) {
578 342         396 die "duplicate at ($col, $row)"
579             # style regions are supposed to be disjoint; enable this for
580             # extra paranoia.
581             if 0 && $found;
582 342         549 $found = $style_region;
583             }
584             }
585 28712   66     62000 return $found && $found->style_attributes;
586             }
587              
588             sub _process_StyleRegion_elt {
589             # Collect a style region for the style attributes we have just defined.
590 549     549   1921 my ($self, $text, %keys) = @_;
591              
592             return
593 549 100       1165 unless $self->attr;
594             my $style_region = Spreadsheet::Gnumeric::StyleRegion->new
595             (start_col => $keys{startCol}, end_col => $keys{endCol},
596             start_row => $keys{startRow}, end_row => $keys{endRow},
597 108         306 style_attributes => $self->style_attributes);
598 108         164 push(@{$self->{_style_regions}}, $style_region);
  108         362  
599             }
600              
601             1;
602              
603             __END__