Merge branch 'master' of github.com:tla/stemmatology
[scpubgit/stemmatology.git] / lib / Text / Tradition / Collation.pm
CommitLineData
dd3b58b0 1package Text::Tradition::Collation;
d047cd52 2
910a0a6d 3use Encode qw( decode_utf8 );
4use File::Temp;
bfcbcecb 5use File::Which;
c9bf3dbf 6use Graph;
8e1394aa 7use IPC::Run qw( run binary );
910a0a6d 8use Text::CSV_XS;
b15511bf 9use Text::Tradition::Collation::Reading;
22222af9 10use Text::Tradition::Collation::RelationshipStore;
63778331 11use Text::Tradition::Error;
df6d9812 12use XML::LibXML;
a344ffcf 13use XML::LibXML::XPathContext;
dd3b58b0 14use Moose;
15
3a2ebbf4 16has 'sequence' => (
d047cd52 17 is => 'ro',
3a2ebbf4 18 isa => 'Graph',
19 default => sub { Graph->new() },
d047cd52 20 handles => {
3a2ebbf4 21 paths => 'edges',
d047cd52 22 },
d047cd52 23 );
3a2ebbf4 24
25has 'relations' => (
26 is => 'ro',
22222af9 27 isa => 'Text::Tradition::Collation::RelationshipStore',
28 handles => {
29 relationships => 'relationships',
30 related_readings => 'related_readings',
202ccb18 31 get_relationship => 'get_relationship',
ee801e17 32 del_relationship => 'del_relationship',
22222af9 33 },
34 writer => '_set_relations',
3a2ebbf4 35 );
dd3b58b0 36
3a2ebbf4 37has 'tradition' => (
38 is => 'ro',
d047cd52 39 isa => 'Text::Tradition',
8d9a1cd8 40 weak_ref => 1,
d047cd52 41 );
dd3b58b0 42
3a2ebbf4 43has 'readings' => (
44 isa => 'HashRef[Text::Tradition::Collation::Reading]',
45 traits => ['Hash'],
46 handles => {
47 reading => 'get',
48 _add_reading => 'set',
49 del_reading => 'delete',
50 has_reading => 'exists',
51 readings => 'values',
52 },
53 default => sub { {} },
54 );
910a0a6d 55
4a8828f0 56has 'wit_list_separator' => (
7854e12e 57 is => 'rw',
58 isa => 'Str',
59 default => ', ',
60 );
61
62has 'baselabel' => (
63 is => 'rw',
64 isa => 'Str',
65 default => 'base text',
66 );
4a8828f0 67
15d2d3df 68has 'linear' => (
69 is => 'rw',
70 isa => 'Bool',
71 default => 1,
72 );
c84275ff 73
ef9d481f 74has 'ac_label' => (
75 is => 'rw',
76 isa => 'Str',
77 default => ' (a.c.)',
78 );
3a2ebbf4 79
4e483aa5 80has 'wordsep' => (
81 is => 'rw',
82 isa => 'Str',
83 default => ' ',
84 );
85
3a2ebbf4 86has 'start' => (
87 is => 'ro',
88 isa => 'Text::Tradition::Collation::Reading',
89 writer => '_set_start',
90 weak_ref => 1,
91 );
92
93has 'end' => (
94 is => 'ro',
95 isa => 'Text::Tradition::Collation::Reading',
96 writer => '_set_end',
97 weak_ref => 1,
98 );
b365fbae 99
100has 'cached_svg' => (
101 is => 'rw',
102 isa => 'Str',
103 predicate => 'has_cached_svg',
104 clearer => 'wipe_svg',
105 );
1dd07bda 106
107has 'cached_table' => (
108 is => 'rw',
109 isa => 'HashRef',
110 predicate => 'has_cached_table',
111 clearer => 'wipe_table',
112 );
c1915ab9 113
114has '_graphcalc_done' => (
115 is => 'rw',
116 isa => 'Bool',
117 default => undef,
118 );
1f563ac3 119
4e5a7b2c 120=head1 NAME
121
122Text::Tradition::Collation - a software model for a text collation
123
124=head1 SYNOPSIS
125
126 use Text::Tradition;
127 my $t = Text::Tradition->new(
128 'name' => 'this is a text',
129 'input' => 'TEI',
130 'file' => '/path/to/tei_parallel_seg_file.xml' );
131
132 my $c = $t->collation;
133 my @readings = $c->readings;
134 my @paths = $c->paths;
135 my @relationships = $c->relationships;
136
137 my $svg_variant_graph = $t->collation->as_svg();
138
139=head1 DESCRIPTION
140
141Text::Tradition is a library for representation and analysis of collated
142texts, particularly medieval ones. The Collation is the central feature of
143a Tradition, where the text, its sequence of readings, and its relationships
144between readings are actually kept.
145
146=head1 CONSTRUCTOR
147
148=head2 new
149
150The constructor. Takes a hash or hashref of the following arguments:
151
152=over
153
154=item * tradition - The Text::Tradition object to which the collation
155belongs. Required.
156
157=item * linear - Whether the collation should be linear; that is, whether
158transposed readings should be treated as two linked readings rather than one,
159and therefore whether the collation graph is acyclic. Defaults to true.
160
4e5a7b2c 161=item * baselabel - The default label for the path taken by a base text
162(if any). Defaults to 'base text'.
163
164=item * wit_list_separator - The string to join a list of witnesses for
165purposes of making labels in display graphs. Defaults to ', '.
166
167=item * ac_label - The extra label to tack onto a witness sigil when
168representing another layer of path for the given witness - that is, when
169a text has more than one possible reading due to scribal corrections or
170the like. Defaults to ' (a.c.)'.
171
4e483aa5 172=item * wordsep - The string used to separate words in the original text.
173Defaults to ' '.
174
4e5a7b2c 175=back
176
177=head1 ACCESSORS
178
179=head2 tradition
180
181=head2 linear
182
4e5a7b2c 183=head2 wit_list_separator
184
185=head2 baselabel
186
187=head2 ac_label
188
4e483aa5 189=head2 wordsep
190
4e5a7b2c 191Simple accessors for collation attributes.
192
193=head2 start
194
195The meta-reading at the start of every witness path.
196
197=head2 end
198
199The meta-reading at the end of every witness path.
200
201=head2 readings
202
203Returns all Reading objects in the graph.
204
205=head2 reading( $id )
206
207Returns the Reading object corresponding to the given ID.
208
209=head2 add_reading( $reading_args )
210
211Adds a new reading object to the collation.
212See L<Text::Tradition::Collation::Reading> for the available arguments.
213
214=head2 del_reading( $object_or_id )
215
216Removes the given reading from the collation, implicitly removing its
217paths and relationships.
218
4e483aa5 219=head2 merge_readings( $main, $second, $concatenate, $with_str )
220
221Merges the $second reading into the $main one. If $concatenate is true, then
222the merged node will carry the text of both readings, concatenated with either
223$with_str (if specified) or a sensible default (the empty string if the
224appropriate 'join_*' flag is set on either reading, or else $self->wordsep.)
4e5a7b2c 225
4e483aa5 226The first two arguments may be either readings or reading IDs.
4e5a7b2c 227
228=head2 has_reading( $id )
229
230Predicate to see whether a given reading ID is in the graph.
231
232=head2 reading_witnesses( $object_or_id )
233
234Returns a list of sigils whose witnesses contain the reading.
235
236=head2 paths
237
238Returns all reading paths within the document - that is, all edges in the
239collation graph. Each path is an arrayref of [ $source, $target ] reading IDs.
240
241=head2 add_path( $source, $target, $sigil )
242
243Links the given readings in the collation in sequence, under the given witness
244sigil. The readings may be specified by object or ID.
245
246=head2 del_path( $source, $target, $sigil )
247
248Links the given readings in the collation in sequence, under the given witness
249sigil. The readings may be specified by object or ID.
250
251=head2 has_path( $source, $target );
252
253Returns true if the two readings are linked in sequence in any witness.
254The readings may be specified by object or ID.
255
256=head2 relationships
257
258Returns all Relationship objects in the collation.
259
260=head2 add_relationship( $reading, $other_reading, $options )
261
262Adds a new relationship of the type given in $options between the two readings,
263which may be specified by object or ID. Returns a value of ( $status, @vectors)
264where $status is true on success, and @vectors is a list of relationship edges
265that were ultimately added.
266See L<Text::Tradition::Collation::Relationship> for the available options.
267
268=cut
dd3b58b0 269
d047cd52 270sub BUILD {
3a2ebbf4 271 my $self = shift;
22222af9 272 $self->_set_relations( Text::Tradition::Collation::RelationshipStore->new( 'collation' => $self ) );
3a2ebbf4 273 $self->_set_start( $self->add_reading( { 'collation' => $self, 'is_start' => 1 } ) );
274 $self->_set_end( $self->add_reading( { 'collation' => $self, 'is_end' => 1 } ) );
d047cd52 275}
784877d9 276
3a2ebbf4 277### Reading construct/destruct functions
278
279sub add_reading {
280 my( $self, $reading ) = @_;
281 unless( ref( $reading ) eq 'Text::Tradition::Collation::Reading' ) {
282 my %args = %$reading;
283 $reading = Text::Tradition::Collation::Reading->new(
284 'collation' => $self,
285 %args );
286 }
287 # First check to see if a reading with this ID exists.
288 if( $self->reading( $reading->id ) ) {
63778331 289 throw( "Collation already has a reading with id " . $reading->id );
3a2ebbf4 290 }
c1915ab9 291 $self->_graphcalc_done(0);
3a2ebbf4 292 $self->_add_reading( $reading->id => $reading );
293 # Once the reading has been added, put it in both graphs.
294 $self->sequence->add_vertex( $reading->id );
22222af9 295 $self->relations->add_reading( $reading->id );
3a2ebbf4 296 return $reading;
eca16057 297};
298
3a2ebbf4 299around del_reading => sub {
300 my $orig = shift;
301 my $self = shift;
302 my $arg = shift;
303
304 if( ref( $arg ) eq 'Text::Tradition::Collation::Reading' ) {
305 $arg = $arg->id;
306 }
3a2ebbf4 307 # Remove the reading from the graphs.
c1915ab9 308 $self->_graphcalc_done(0);
4e483aa5 309 $self->_clear_cache; # Explicitly clear caches to GC the reading
3a2ebbf4 310 $self->sequence->delete_vertex( $arg );
22222af9 311 $self->relations->delete_reading( $arg );
3a2ebbf4 312
313 # Carry on.
314 $self->$orig( $arg );
315};
7854e12e 316
4e483aa5 317=begin testing
318
319use Text::Tradition;
320
321my $cxfile = 't/data/Collatex-16.xml';
322my $t = Text::Tradition->new(
323 'name' => 'inline',
324 'input' => 'CollateX',
325 'file' => $cxfile,
326 );
327my $c = $t->collation;
328
329my $rno = scalar $c->readings;
330# Split n21 for testing purposes
331my $new_r = $c->add_reading( { 'id' => 'n21p0', 'text' => 'un', 'join_next' => 1 } );
332my $old_r = $c->reading( 'n21' );
333$old_r->alter_text( 'to' );
334$c->del_path( 'n20', 'n21', 'A' );
335$c->add_path( 'n20', 'n21p0', 'A' );
336$c->add_path( 'n21p0', 'n21', 'A' );
337$c->flatten_ranks();
338ok( $c->reading( 'n21p0' ), "New reading exists" );
339is( scalar $c->readings, $rno, "Reading add offset by flatten_ranks" );
340
341# Combine n3 and n4
342$c->merge_readings( 'n3', 'n4', 1 );
343ok( !$c->reading('n4'), "Reading n4 is gone" );
344is( $c->reading('n3')->text, 'with his', "Reading n3 has both words" );
345
346# Collapse n25 and n26
347$c->merge_readings( 'n25', 'n26' );
348ok( !$c->reading('n26'), "Reading n26 is gone" );
349is( $c->reading('n25')->text, 'rood', "Reading n25 has an unchanged word" );
350
351# Combine n21 and n21p0
352my $remaining = $c->reading('n21');
353$remaining ||= $c->reading('n22'); # one of these should still exist
354$c->merge_readings( 'n21p0', $remaining, 1 );
355ok( !$c->reading('n21'), "Reading $remaining is gone" );
356is( $c->reading('n21p0')->text, 'unto', "Reading n21p0 merged correctly" );
357
358=end testing
359
360=cut
7854e12e 361
3a2ebbf4 362sub merge_readings {
363 my $self = shift;
364
365 # We only need the IDs for adding paths to the graph, not the reading
366 # objects themselves.
4e483aa5 367 my( $kept, $deleted, $combine, $combine_char ) = $self->_stringify_args( @_ );
c1915ab9 368 $self->_graphcalc_done(0);
3a2ebbf4 369
370 # The kept reading should inherit the paths and the relationships
371 # of the deleted reading.
372 foreach my $path ( $self->sequence->edges_at( $deleted ) ) {
373 my @vector = ( $kept );
374 push( @vector, $path->[1] ) if $path->[0] eq $deleted;
375 unshift( @vector, $path->[0] ) if $path->[1] eq $deleted;
49d4f2ac 376 next if $vector[0] eq $vector[1]; # Don't add a self loop
3a2ebbf4 377 my %wits = %{$self->sequence->get_edge_attributes( @$path )};
378 $self->sequence->add_edge( @vector );
379 my $fwits = $self->sequence->get_edge_attributes( @vector );
380 @wits{keys %$fwits} = values %$fwits;
381 $self->sequence->set_edge_attributes( @vector, \%wits );
382 }
22222af9 383 $self->relations->merge_readings( $kept, $deleted, $combine_char );
3a2ebbf4 384
385 # Do the deletion deed.
4e483aa5 386 if( $combine ) {
49d4f2ac 387 my $kept_obj = $self->reading( $kept );
4e483aa5 388 my $del_obj = $self->reading( $deleted );
389 my $joinstr = $combine_char;
390 unless( defined $joinstr ) {
391 $joinstr = '' if $kept_obj->join_next || $del_obj->join_prior;
392 $joinstr = $self->wordsep unless defined $joinstr;
393 }
394 $kept_obj->alter_text( join( $joinstr, $kept_obj->text, $del_obj->text ) );
49d4f2ac 395 }
3a2ebbf4 396 $self->del_reading( $deleted );
397}
7854e12e 398
3265b0ce 399
3a2ebbf4 400# Helper function for manipulating the graph.
401sub _stringify_args {
4e483aa5 402 my( $self, $first, $second, @args ) = @_;
3a2ebbf4 403 $first = $first->id
404 if ref( $first ) eq 'Text::Tradition::Collation::Reading';
405 $second = $second->id
406 if ref( $second ) eq 'Text::Tradition::Collation::Reading';
4e483aa5 407 return( $first, $second, @args );
3a2ebbf4 408}
df6d9812 409
4e5a7b2c 410# Helper function for manipulating the graph.
411sub _objectify_args {
412 my( $self, $first, $second, $arg ) = @_;
413 $first = $self->reading( $first )
414 unless ref( $first ) eq 'Text::Tradition::Collation::Reading';
415 $second = $self->reading( $second )
416 unless ref( $second ) eq 'Text::Tradition::Collation::Reading';
417 return( $first, $second, $arg );
418}
3a2ebbf4 419### Path logic
420
421sub add_path {
422 my $self = shift;
423
424 # We only need the IDs for adding paths to the graph, not the reading
425 # objects themselves.
426 my( $source, $target, $wit ) = $self->_stringify_args( @_ );
427
c1915ab9 428 $self->_graphcalc_done(0);
3a2ebbf4 429 # Connect the readings
430 $self->sequence->add_edge( $source, $target );
431 # Note the witness in question
432 $self->sequence->set_edge_attribute( $source, $target, $wit, 1 );
b15511bf 433};
434
3a2ebbf4 435sub del_path {
436 my $self = shift;
49d4f2ac 437 my @args;
438 if( ref( $_[0] ) eq 'ARRAY' ) {
439 my $e = shift @_;
440 @args = ( @$e, @_ );
441 } else {
442 @args = @_;
443 }
3a2ebbf4 444
445 # We only need the IDs for adding paths to the graph, not the reading
446 # objects themselves.
49d4f2ac 447 my( $source, $target, $wit ) = $self->_stringify_args( @args );
3a2ebbf4 448
c1915ab9 449 $self->_graphcalc_done(0);
3a2ebbf4 450 if( $self->sequence->has_edge_attribute( $source, $target, $wit ) ) {
49d4f2ac 451 $self->sequence->delete_edge_attribute( $source, $target, $wit );
3a2ebbf4 452 }
453 unless( keys %{$self->sequence->get_edge_attributes( $source, $target )} ) {
454 $self->sequence->delete_edge( $source, $target );
455 }
784877d9 456}
457
3a2ebbf4 458
15d2d3df 459# Extra graph-alike utility
460sub has_path {
3a2ebbf4 461 my $self = shift;
462 my( $source, $target, $wit ) = $self->_stringify_args( @_ );
463 return undef unless $self->sequence->has_edge( $source, $target );
464 return $self->sequence->has_edge_attribute( $source, $target, $wit );
b15511bf 465}
466
4e5a7b2c 467=head2 clear_witness( @sigil_list )
3a2ebbf4 468
4e5a7b2c 469Clear the given witnesses out of the collation entirely, removing references
470to them in paths, and removing readings that belong only to them. Should only
471be called via $tradition->del_witness.
3a2ebbf4 472
473=cut
474
4e5a7b2c 475sub clear_witness {
476 my( $self, @sigils ) = @_;
477
c1915ab9 478 $self->_graphcalc_done(0);
4e5a7b2c 479 # Clear the witness(es) out of the paths
480 foreach my $e ( $self->paths ) {
481 foreach my $sig ( @sigils ) {
482 $self->del_path( $e, $sig );
483 }
484 }
485
486 # Clear out the newly unused readings
487 foreach my $r ( $self->readings ) {
488 unless( $self->reading_witnesses( $r ) ) {
489 $self->del_reading( $r );
490 }
491 }
492}
3a2ebbf4 493
494sub add_relationship {
495 my $self = shift;
22222af9 496 my( $source, $target, $opts ) = $self->_stringify_args( @_ );
63778331 497 my( @vectors ) = $self->relations->add_relationship( $source,
64ae6270 498 $self->reading( $source ), $target, $self->reading( $target ), $opts );
c1915ab9 499 $self->_graphcalc_done(0);
63778331 500 return @vectors;
22222af9 501}
ef9d481f 502
ca6e6095 503around qw/ get_relationship del_relationship / => sub {
504 my $orig = shift;
505 my $self = shift;
506 my @args = @_;
507 if( @args == 1 && ref( $args[0] ) eq 'ARRAY' ) {
508 @args = @{$_[0]};
509 }
510 my( $source, $target ) = $self->_stringify_args( @args );
511 $self->$orig( $source, $target );
512};
513
22222af9 514=head2 reading_witnesses( $reading )
910a0a6d 515
22222af9 516Return a list of sigils corresponding to the witnesses in which the reading appears.
3265b0ce 517
22222af9 518=cut
1d310495 519
1d310495 520sub reading_witnesses {
521 my( $self, $reading ) = @_;
522 # We need only check either the incoming or the outgoing edges; I have
96dc90ec 523 # arbitrarily chosen "incoming". Thus, special-case the start node.
524 if( $reading eq $self->start ) {
525 return map { $_->sigil } $self->tradition->witnesses;
526 }
1d310495 527 my %all_witnesses;
528 foreach my $e ( $self->sequence->edges_to( $reading ) ) {
529 my $wits = $self->sequence->get_edge_attributes( @$e );
530 @all_witnesses{ keys %$wits } = 1;
531 }
c12bb878 532 my $acstr = $self->ac_label;
533 foreach my $acwit ( grep { $_ =~ s/^(.*)\Q$acstr\E$/$1/ } keys %all_witnesses ) {
534 delete $all_witnesses{$acwit.$acstr} if exists $all_witnesses{$acwit};
535 }
1d310495 536 return keys %all_witnesses;
910a0a6d 537}
538
4e5a7b2c 539=head1 OUTPUT METHODS
8e1394aa 540
0ecb975c 541=head2 as_svg( \%options )
8e1394aa 542
0068967c 543Returns an SVG string that represents the graph, via as_dot and graphviz.
bfcbcecb 544See as_dot for a list of options. Must have GraphViz (dot) installed to run.
8e1394aa 545
546=cut
547
548sub as_svg {
0ecb975c 549 my( $self, $opts ) = @_;
bfcbcecb 550 throw( "Need GraphViz installed to output SVG" )
551 unless File::Which::which( 'dot' );
e247aad1 552 my $want_subgraph = exists $opts->{'from'} || exists $opts->{'to'};
c1915ab9 553 $self->calculate_ranks() unless $self->_graphcalc_done;
e247aad1 554 if( !$self->has_cached_svg || $opts->{'recalc'} || $want_subgraph ) {
b365fbae 555 my @cmd = qw/dot -Tsvg/;
556 my( $svg, $err );
557 my $dotfile = File::Temp->new();
558 ## USE FOR DEBUGGING
559 # $dotfile->unlink_on_destroy(0);
560 binmode $dotfile, ':utf8';
561 print $dotfile $self->as_dot( $opts );
562 push( @cmd, $dotfile->filename );
563 run( \@cmd, ">", binary(), \$svg );
e247aad1 564 $svg = decode_utf8( $svg );
565 $self->cached_svg( $svg ) unless $want_subgraph;
566 return $svg;
567 } else {
568 return $self->cached_svg;
b365fbae 569 }
8e1394aa 570}
571
b22576c6 572
0ecb975c 573=head2 as_dot( \%options )
b22576c6 574
0ecb975c 575Returns a string that is the collation graph expressed in dot
576(i.e. GraphViz) format. Options include:
b22576c6 577
0ecb975c 578=over 4
b22576c6 579
0ecb975c 580=item * from
b22576c6 581
0ecb975c 582=item * to
df6d9812 583
0ecb975c 584=item * color_common
585
586=back
df6d9812 587
588=cut
589
590sub as_dot {
0ecb975c 591 my( $self, $opts ) = @_;
592 my $startrank = $opts->{'from'} if $opts;
593 my $endrank = $opts->{'to'} if $opts;
594 my $color_common = $opts->{'color_common'} if $opts;
b365fbae 595 my $STRAIGHTENHACK = !$startrank && !$endrank && $self->end->rank
596 && $self->end->rank > 100;
597
b22576c6 598 # Check the arguments
599 if( $startrank ) {
600 return if $endrank && $startrank > $endrank;
601 return if $startrank > $self->end->rank;
602 }
603 if( defined $endrank ) {
604 return if $endrank < 0;
f1b3b33a 605 $endrank = undef if $endrank == $self->end->rank;
b22576c6 606 }
607
67da8d6c 608 my $graph_name = $self->tradition->name;
609 $graph_name =~ s/[^\w\s]//g;
610 $graph_name = join( '_', split( /\s+/, $graph_name ) );
f13b5582 611
612 my %graph_attrs = (
613 'rankdir' => 'LR',
614 'bgcolor' => 'none',
615 );
616 my %node_attrs = (
b8990398 617 'fontsize' => 14,
f13b5582 618 'fillcolor' => 'white',
619 'style' => 'filled',
620 'shape' => 'ellipse'
621 );
622 my %edge_attrs = (
623 'arrowhead' => 'open',
624 'color' => '#000000',
625 'fontcolor' => '#000000',
626 );
627
67da8d6c 628 my $dot = sprintf( "digraph %s {\n", $graph_name );
f13b5582 629 $dot .= "\tgraph " . _dot_attr_string( \%graph_attrs ) . ";\n";
630 $dot .= "\tnode " . _dot_attr_string( \%node_attrs ) . ";\n";
df6d9812 631
b22576c6 632 # Output substitute start/end readings if necessary
633 if( $startrank ) {
634 $dot .= "\t\"#SUBSTART#\" [ label=\"...\" ];\n";
635 }
636 if( $endrank ) {
637 $dot .= "\t\"#SUBEND#\" [ label=\"...\" ];\n";
638 }
b365fbae 639 if( $STRAIGHTENHACK ) {
640 ## HACK part 1
641 $dot .= "\tsubgraph { rank=same \"#START#\" \"#SILENT#\" }\n";
642 $dot .= "\t\"#SILENT#\" [ shape=diamond,color=white,penwidth=0,label=\"\" ];"
643 }
b22576c6 644 my %used; # Keep track of the readings that actually appear in the graph
30ddc24c 645 # Sort the readings by rank if we have ranks; this speeds layout.
646 my @all_readings = $self->end->has_rank
647 ? sort { $a->rank <=> $b->rank } $self->readings
648 : $self->readings;
4633f9e4 649 # TODO Refrain from outputting lacuna nodes - just grey out the edges.
30ddc24c 650 foreach my $reading ( @all_readings ) {
b22576c6 651 # Only output readings within our rank range.
652 next if $startrank && $reading->rank < $startrank;
653 next if $endrank && $reading->rank > $endrank;
654 $used{$reading->id} = 1;
910a0a6d 655 # Need not output nodes without separate labels
3a2ebbf4 656 next if $reading->id eq $reading->text;
d4b75f44 657 my $rattrs;
30f0df34 658 my $label = $reading->text;
629e27b0 659 $label .= '-' if $reading->join_next;
660 $label = "-$label" if $reading->join_prior;
8f9cab7b 661 $label =~ s/\"/\\\"/g;
d4b75f44 662 $rattrs->{'label'} = $label;
0ecb975c 663 $rattrs->{'fillcolor'} = '#b3f36d' if $reading->is_common && $color_common;
d4b75f44 664 $dot .= sprintf( "\t\"%s\" %s;\n", $reading->id, _dot_attr_string( $rattrs ) );
df6d9812 665 }
3a2ebbf4 666
30ddc24c 667 # Add the real edges. Need to weight one edge per rank jump, in a
668 # continuous line.
b365fbae 669 # my $weighted = $self->_add_edge_weights;
b22576c6 670 my @edges = $self->paths;
3bdec618 671 my( %substart, %subend );
b22576c6 672 foreach my $edge ( @edges ) {
673 # Do we need to output this edge?
508fd430 674 if( $used{$edge->[0]} && $used{$edge->[1]} ) {
027d819c 675 my $label = $self->_path_display_label( $self->path_witnesses( $edge ) );
f13b5582 676 my $variables = { %edge_attrs, 'label' => $label };
30ddc24c 677
b22576c6 678 # Account for the rank gap if necessary
30ddc24c 679 my $rank0 = $self->reading( $edge->[0] )->rank
680 if $self->reading( $edge->[0] )->has_rank;
681 my $rank1 = $self->reading( $edge->[1] )->rank
682 if $self->reading( $edge->[1] )->has_rank;
683 if( defined $rank0 && defined $rank1 && $rank1 - $rank0 > 1 ) {
684 $variables->{'minlen'} = $rank1 - $rank0;
685 }
686
687 # Add the calculated edge weights
b365fbae 688 # if( exists $weighted->{$edge->[0]}
e247aad1 689 # && $weighted->{$edge->[0]} eq $edge->[1] ) {
690 # # $variables->{'color'} = 'red';
691 # $variables->{'weight'} = 3.0;
692 # }
30ddc24c 693
508fd430 694 # EXPERIMENTAL: make edge width reflect no. of witnesses
695 my $extrawidth = scalar( $self->path_witnesses( $edge ) ) * 0.2;
696 $variables->{'penwidth'} = $extrawidth + 0.8; # gives 1 for a single wit
697
f13b5582 698 my $varopts = _dot_attr_string( $variables );
699 $dot .= sprintf( "\t\"%s\" -> \"%s\" %s;\n",
700 $edge->[0], $edge->[1], $varopts );
3bdec618 701 } elsif( $used{$edge->[0]} ) {
702 $subend{$edge->[0]} = 1;
703 } elsif( $used{$edge->[1]} ) {
704 $substart{$edge->[1]} = 1;
b22576c6 705 }
df6d9812 706 }
3bdec618 707 # Add substitute start and end edges if necessary
708 foreach my $node ( keys %substart ) {
027d819c 709 my $witstr = $self->_path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
f13b5582 710 my $variables = { %edge_attrs, 'label' => $witstr };
711 my $varopts = _dot_attr_string( $variables );
712 $dot .= "\t\"#SUBSTART#\" -> \"$node\" $varopts;";
3bdec618 713 }
714 foreach my $node ( keys %subend ) {
027d819c 715 my $witstr = $self->_path_display_label ( $self->reading_witnesses( $self->reading( $node ) ) );
f13b5582 716 my $variables = { %edge_attrs, 'label' => $witstr };
717 my $varopts = _dot_attr_string( $variables );
718 $dot .= "\t\"$node\" -> \"#SUBEND#\" $varopts;";
3bdec618 719 }
b365fbae 720 # HACK part 2
721 if( $STRAIGHTENHACK ) {
722 $dot .= "\t\"#END#\" -> \"#SILENT#\" [ color=white,penwidth=0 ];\n";
723 }
30ddc24c 724
df6d9812 725 $dot .= "}\n";
726 return $dot;
727}
728
f13b5582 729sub _dot_attr_string {
730 my( $hash ) = @_;
731 my @attrs;
732 foreach my $k ( sort keys %$hash ) {
733 my $v = $hash->{$k};
734 push( @attrs, $k.'="'.$v.'"' );
735 }
736 return( '[ ' . join( ', ', @attrs ) . ' ]' );
737}
738
30ddc24c 739sub _add_edge_weights {
740 my $self = shift;
741 # Walk the graph from START to END, choosing the successor node with
742 # the largest number of witness paths each time.
743 my $weighted = {};
744 my $curr = $self->start->id;
008fc8a6 745 my $ranked = $self->end->has_rank;
30ddc24c 746 while( $curr ne $self->end->id ) {
008fc8a6 747 my $rank = $ranked ? $self->reading( $curr )->rank : 0;
30ddc24c 748 my @succ = sort { $self->path_witnesses( $curr, $a )
749 <=> $self->path_witnesses( $curr, $b ) }
750 $self->sequence->successors( $curr );
751 my $next = pop @succ;
008fc8a6 752 my $nextrank = $ranked ? $self->reading( $next )->rank : 0;
30ddc24c 753 # Try to avoid lacunae in the weighted path.
008fc8a6 754 while( @succ &&
755 ( $self->reading( $next )->is_lacuna ||
756 $nextrank - $rank > 1 ) ){
30ddc24c 757 $next = pop @succ;
758 }
759 $weighted->{$curr} = $next;
760 $curr = $next;
761 }
762 return $weighted;
763}
764
027d819c 765=head2 path_witnesses( $edge )
766
767Returns the list of sigils whose witnesses are associated with the given edge.
768The edge can be passed as either an array or an arrayref of ( $source, $target ).
769
770=cut
771
3a2ebbf4 772sub path_witnesses {
773 my( $self, @edge ) = @_;
774 # If edge is an arrayref, cope.
775 if( @edge == 1 && ref( $edge[0] ) eq 'ARRAY' ) {
776 my $e = shift @edge;
777 @edge = @$e;
778 }
779 my @wits = keys %{$self->sequence->get_edge_attributes( @edge )};
508fd430 780 return @wits;
3a2ebbf4 781}
782
027d819c 783sub _path_display_label {
508fd430 784 my $self = shift;
785 my @wits = sort @_;
8f9cab7b 786 my $maj = scalar( $self->tradition->witnesses ) * 0.6;
787 if( scalar @wits > $maj ) {
f13b5582 788 # TODO break out a.c. wits
8f9cab7b 789 return 'majority';
790 } else {
791 return join( ', ', @wits );
792 }
793}
1dd07bda 794
bf6e338d 795=head2 readings_at_rank( $rank )
1dd07bda 796
bf6e338d 797Returns a list of readings at a given rank, taken from the alignment table.
1dd07bda 798
799=cut
800
bf6e338d 801sub readings_at_rank {
1dd07bda 802 my( $self, $rank ) = @_;
bf6e338d 803 my $table = $self->alignment_table;
804 # Table rank is real rank - 1.
805 my @elements = map { $_->{'tokens'}->[$rank-1] } @{$table->{'alignment'}};
806 my %readings;
807 foreach my $e ( @elements ) {
808 next unless ref( $e ) eq 'HASH';
809 next unless exists $e->{'t'};
810 $readings{$e->{'t'}->id} = $e->{'t'};
811 }
812 return values %readings;
1dd07bda 813}
8f9cab7b 814
4e5a7b2c 815=head2 as_graphml
8e1394aa 816
4e5a7b2c 817Returns a GraphML representation of the collation. The GraphML will contain
818two graphs. The first expresses the attributes of the readings and the witness
819paths that link them; the second expresses the relationships that link the
820readings. This is the native transfer format for a tradition.
8e1394aa 821
56eefa04 822=begin testing
823
824use Text::Tradition;
825
826my $READINGS = 311;
827my $PATHS = 361;
828
829my $datafile = 't/data/florilegium_tei_ps.xml';
830my $tradition = Text::Tradition->new( 'input' => 'TEI',
831 'name' => 'test0',
832 'file' => $datafile,
833 'linear' => 1 );
834
835ok( $tradition, "Got a tradition object" );
836is( scalar $tradition->witnesses, 13, "Found all witnesses" );
837ok( $tradition->collation, "Tradition has a collation" );
838
839my $c = $tradition->collation;
840is( scalar $c->readings, $READINGS, "Collation has all readings" );
841is( scalar $c->paths, $PATHS, "Collation has all paths" );
842is( scalar $c->relationships, 0, "Collation has all relationships" );
843
844# Add a few relationships
845$c->add_relationship( 'w123', 'w125', { 'type' => 'collated' } );
846$c->add_relationship( 'w193', 'w196', { 'type' => 'collated' } );
847$c->add_relationship( 'w257', 'w262', { 'type' => 'transposition' } );
848
849# Now write it to GraphML and parse it again.
850
851my $graphml = $c->as_graphml;
852my $st = Text::Tradition->new( 'input' => 'Self', 'string' => $graphml );
853is( scalar $st->collation->readings, $READINGS, "Reparsed collation has all readings" );
854is( scalar $st->collation->paths, $PATHS, "Reparsed collation has all paths" );
855is( scalar $st->collation->relationships, 3, "Reparsed collation has new relationships" );
856
857=end testing
858
8e1394aa 859=cut
860
861sub as_graphml {
3a2ebbf4 862 my( $self ) = @_;
3d14b48e 863 $self->calculate_ranks unless $self->_graphcalc_done;
864
8e1394aa 865 # Some namespaces
866 my $graphml_ns = 'http://graphml.graphdrawing.org/xmlns';
867 my $xsi_ns = 'http://www.w3.org/2001/XMLSchema-instance';
868 my $graphml_schema = 'http://graphml.graphdrawing.org/xmlns ' .
910a0a6d 869 'http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd';
8e1394aa 870
871 # Create the document and root node
872 my $graphml = XML::LibXML->createDocument( "1.0", "UTF-8" );
873 my $root = $graphml->createElementNS( $graphml_ns, 'graphml' );
874 $graphml->setDocumentElement( $root );
875 $root->setNamespace( $xsi_ns, 'xsi', 0 );
876 $root->setAttributeNS( $xsi_ns, 'schemaLocation', $graphml_schema );
bbd064a9 877
878 # List of attribute types to save on our objects and their corresponding
879 # GraphML types
880 my %save_types = (
881 'Str' => 'string',
882 'Int' => 'int',
883 'Bool' => 'boolean',
884 'RelationshipType' => 'string',
885 'RelationshipScope' => 'string',
886 );
887
888 # List of attribute names *not* to save on our objects.
889 # We will also not save any attribute beginning with _.
890 my %skipsave;
891 map { $skipsave{$_} = 1 } qw/ cached_svg /;
8e1394aa 892
bbd064a9 893 # Add the data keys for the graph. Include an extra key 'version' for the
894 # GraphML output version.
e309421a 895 my %graph_data_keys;
896 my $gdi = 0;
bbd064a9 897 my %graph_attributes = ( 'version' => 'string' );
898 # Graph attributes include those of Tradition and those of Collation.
899 my %gattr_from;
900 my $tmeta = $self->tradition->meta;
901 my $cmeta = $self->meta;
902 map { $gattr_from{$_->name} = 'Tradition' } $tmeta->get_all_attributes;
903 map { $gattr_from{$_->name} = 'Collation' } $cmeta->get_all_attributes;
904 foreach my $attr ( ( $tmeta->get_all_attributes, $cmeta->get_all_attributes ) ) {
905 next if $attr->name =~ /^_/;
906 next if $skipsave{$attr->name};
907 next unless $save_types{$attr->type_constraint->name};
908 $graph_attributes{$attr->name} = $save_types{$attr->type_constraint->name};
909 }
910
911 foreach my $datum ( sort keys %graph_attributes ) {
e309421a 912 $graph_data_keys{$datum} = 'dg'.$gdi++;
913 my $key = $root->addNewChild( $graphml_ns, 'key' );
914 $key->setAttribute( 'attr.name', $datum );
bbd064a9 915 $key->setAttribute( 'attr.type', $graph_attributes{$datum} );
e309421a 916 $key->setAttribute( 'for', 'graph' );
917 $key->setAttribute( 'id', $graph_data_keys{$datum} );
918 }
f6066bac 919
bbd064a9 920 # Add the data keys for reading nodes
921 my %reading_attributes;
922 my $rmeta = Text::Tradition::Collation::Reading->meta;
923 foreach my $attr( $rmeta->get_all_attributes ) {
924 next if $attr->name =~ /^_/;
925 next if $skipsave{$attr->name};
926 next unless $save_types{$attr->type_constraint->name};
927 $reading_attributes{$attr->name} = $save_types{$attr->type_constraint->name};
928 }
ef9d481f 929 my %node_data_keys;
930 my $ndi = 0;
bbd064a9 931 foreach my $datum ( sort keys %reading_attributes ) {
910a0a6d 932 $node_data_keys{$datum} = 'dn'.$ndi++;
933 my $key = $root->addNewChild( $graphml_ns, 'key' );
934 $key->setAttribute( 'attr.name', $datum );
bbd064a9 935 $key->setAttribute( 'attr.type', $reading_attributes{$datum} );
910a0a6d 936 $key->setAttribute( 'for', 'node' );
937 $key->setAttribute( 'id', $node_data_keys{$datum} );
8e1394aa 938 }
939
bbd064a9 940 # Add the data keys for edges, that is, paths and relationships. Path
941 # data does not come from a Moose class so is here manually.
ef9d481f 942 my $edi = 0;
943 my %edge_data_keys;
bbd064a9 944 my %edge_attributes = (
3a2ebbf4 945 witness => 'string', # ID/label for a path
3a2ebbf4 946 extra => 'boolean', # Path key
3a2ebbf4 947 );
bbd064a9 948 my @path_attributes = keys %edge_attributes; # track our manual additions
949 my $pmeta = Text::Tradition::Collation::Relationship->meta;
950 foreach my $attr( $pmeta->get_all_attributes ) {
951 next if $attr->name =~ /^_/;
952 next if $skipsave{$attr->name};
953 next unless $save_types{$attr->type_constraint->name};
954 $edge_attributes{$attr->name} = $save_types{$attr->type_constraint->name};
955 }
956 foreach my $datum ( sort keys %edge_attributes ) {
3a2ebbf4 957 $edge_data_keys{$datum} = 'de'.$edi++;
910a0a6d 958 my $key = $root->addNewChild( $graphml_ns, 'key' );
3a2ebbf4 959 $key->setAttribute( 'attr.name', $datum );
bbd064a9 960 $key->setAttribute( 'attr.type', $edge_attributes{$datum} );
910a0a6d 961 $key->setAttribute( 'for', 'edge' );
3a2ebbf4 962 $key->setAttribute( 'id', $edge_data_keys{$datum} );
8e1394aa 963 }
3a2ebbf4 964
22222af9 965 # Add the collation graph itself
2c669bca 966 my $sgraph = $root->addNewChild( $graphml_ns, 'graph' );
967 $sgraph->setAttribute( 'edgedefault', 'directed' );
968 $sgraph->setAttribute( 'id', $self->tradition->name );
969 $sgraph->setAttribute( 'parse.edgeids', 'canonical' );
970 $sgraph->setAttribute( 'parse.edges', scalar($self->paths) );
971 $sgraph->setAttribute( 'parse.nodeids', 'canonical' );
972 $sgraph->setAttribute( 'parse.nodes', scalar($self->readings) );
973 $sgraph->setAttribute( 'parse.order', 'nodesfirst' );
22222af9 974
e309421a 975 # Collation attribute data
bbd064a9 976 foreach my $datum ( keys %graph_attributes ) {
977 my $value;
978 if( $datum eq 'version' ) {
979 $value = '3.1';
980 } elsif( $gattr_from{$datum} eq 'Tradition' ) {
981 $value = $self->tradition->$datum;
982 } else {
983 $value = $self->$datum;
984 }
2c669bca 985 _add_graphml_data( $sgraph, $graph_data_keys{$datum}, $value );
e309421a 986 }
8e1394aa 987
988 my $node_ctr = 0;
989 my %node_hash;
22222af9 990 # Add our readings to the graph
3a2ebbf4 991 foreach my $n ( sort { $a->id cmp $b->id } $self->readings ) {
2c669bca 992 # Add to the main graph
993 my $node_el = $sgraph->addNewChild( $graphml_ns, 'node' );
910a0a6d 994 my $node_xmlid = 'n' . $node_ctr++;
3a2ebbf4 995 $node_hash{ $n->id } = $node_xmlid;
910a0a6d 996 $node_el->setAttribute( 'id', $node_xmlid );
bbd064a9 997 foreach my $d ( keys %reading_attributes ) {
255875b8 998 my $nval = $n->$d;
999 _add_graphml_data( $node_el, $node_data_keys{$d}, $nval )
1000 if defined $nval;
1001 }
b15511bf 1002 }
1003
2c669bca 1004 # Add the path edges to the sequence graph
df6d9812 1005 my $edge_ctr = 0;
3a2ebbf4 1006 foreach my $e ( sort { $a->[0] cmp $b->[0] } $self->sequence->edges() ) {
1007 # We add an edge in the graphml for every witness in $e.
508fd430 1008 foreach my $wit ( sort $self->path_witnesses( $e ) ) {
3a2ebbf4 1009 my( $id, $from, $to ) = ( 'e'.$edge_ctr++,
1010 $node_hash{ $e->[0] },
1011 $node_hash{ $e->[1] } );
2c669bca 1012 my $edge_el = $sgraph->addNewChild( $graphml_ns, 'edge' );
3a2ebbf4 1013 $edge_el->setAttribute( 'source', $from );
1014 $edge_el->setAttribute( 'target', $to );
1015 $edge_el->setAttribute( 'id', $id );
3a2ebbf4 1016
1017 # It's a witness path, so add the witness
1018 my $base = $wit;
1019 my $key = $edge_data_keys{'witness'};
1020 # Is this an ante-corr witness?
1021 my $aclabel = $self->ac_label;
1022 if( $wit =~ /^(.*)\Q$aclabel\E$/ ) {
1023 # Keep the base witness
1024 $base = $1;
1025 # ...and record that this is an 'extra' reading path
1026 _add_graphml_data( $edge_el, $edge_data_keys{'extra'}, $aclabel );
1027 }
1028 _add_graphml_data( $edge_el, $edge_data_keys{'witness'}, $base );
1029 }
1030 }
1031
22222af9 1032 # Add the relationship graph to the XML
bbd064a9 1033 map { delete $edge_data_keys{$_} } @path_attributes;
027d819c 1034 $self->relations->_as_graphml( $graphml_ns, $root, \%node_hash,
2626f709 1035 $node_data_keys{'id'}, \%edge_data_keys );
8e1394aa 1036
94c00c71 1037 # Save and return the thing
1038 my $result = decode_utf8( $graphml->toString(1) );
94c00c71 1039 return $result;
df6d9812 1040}
1041
b15511bf 1042sub _add_graphml_data {
1043 my( $el, $key, $value ) = @_;
b15511bf 1044 return unless defined $value;
c9bf3dbf 1045 my $data_el = $el->addNewChild( $el->namespaceURI, 'data' );
b15511bf 1046 $data_el->setAttribute( 'key', $key );
1047 $data_el->appendText( $value );
8e1394aa 1048}
1049
4e5a7b2c 1050=head2 as_csv
910a0a6d 1051
1052Returns a CSV alignment table representation of the collation graph, one
2c669bca 1053row per witness (or witness uncorrected.)
910a0a6d 1054
1055=cut
1056
1057sub as_csv {
3a2ebbf4 1058 my( $self ) = @_;
1dd07bda 1059 my $table = $self->alignment_table;
910a0a6d 1060 my $csv = Text::CSV_XS->new( { binary => 1, quote_null => 0 } );
1061 my @result;
2c669bca 1062 # Make the header row
1063 $csv->combine( map { $_->{'witness'} } @{$table->{'alignment'}} );
1064 push( @result, decode_utf8( $csv->string ) );
1065 # Make the rest of the rows
1066 foreach my $idx ( 0 .. $table->{'length'} - 1 ) {
566f4595 1067 my @rowobjs = map { $_->{'tokens'}->[$idx] } @{$table->{'alignment'}};
1dd07bda 1068 my @row = map { $_ ? $_->{'t'}->text : $_ } @rowobjs;
2c669bca 1069 $csv->combine( @row );
910a0a6d 1070 push( @result, decode_utf8( $csv->string ) );
1071 }
3a2ebbf4 1072 return join( "\n", @result );
910a0a6d 1073}
1074
1dd07bda 1075=head2 alignment_table( $use_refs, $include_witnesses )
2c669bca 1076
566f4595 1077Return a reference to an alignment table, in a slightly enhanced CollateX
1078format which looks like this:
1079
1080 $table = { alignment => [ { witness => "SIGIL",
4e5a7b2c 1081 tokens => [ { t => "TEXT" }, ... ] },
566f4595 1082 { witness => "SIG2",
4e5a7b2c 1083 tokens => [ { t => "TEXT" }, ... ] },
566f4595 1084 ... ],
1085 length => TEXTLEN };
1086
1087If $use_refs is set to 1, the reading object is returned in the table
1088instead of READINGTEXT; if not, the text of the reading is returned.
4e5a7b2c 1089
1090If $include_witnesses is set to a hashref, only the witnesses whose sigil
566f4595 1091keys have a true hash value will be included.
2c669bca 1092
1093=cut
9f3ba6f7 1094
1dd07bda 1095sub alignment_table {
1096 my( $self ) = @_;
c1915ab9 1097 $self->calculate_ranks() unless $self->_graphcalc_done;
1dd07bda 1098 return $self->cached_table if $self->has_cached_table;
1099
0ecb975c 1100 # Make sure we can do this
1101 throw( "Need a linear graph in order to make an alignment table" )
1102 unless $self->linear;
1103 $self->calculate_ranks unless $self->end->has_rank;
1104
2c669bca 1105 my $table = { 'alignment' => [], 'length' => $self->end->rank - 1 };
3a2ebbf4 1106 my @all_pos = ( 1 .. $self->end->rank - 1 );
68454b71 1107 foreach my $wit ( sort { $a->sigil cmp $b->sigil } $self->tradition->witnesses ) {
eca16057 1108 # print STDERR "Making witness row(s) for " . $wit->sigil . "\n";
1f7aa795 1109 my @wit_path = $self->reading_sequence( $self->start, $self->end, $wit->sigil );
1dd07bda 1110 my @row = _make_witness_row( \@wit_path, \@all_pos );
2c669bca 1111 push( @{$table->{'alignment'}},
1112 { 'witness' => $wit->sigil, 'tokens' => \@row } );
1f7aa795 1113 if( $wit->is_layered ) {
1114 my @wit_ac_path = $self->reading_sequence( $self->start, $self->end,
861c3e27 1115 $wit->sigil.$self->ac_label );
1dd07bda 1116 my @ac_row = _make_witness_row( \@wit_ac_path, \@all_pos );
2c669bca 1117 push( @{$table->{'alignment'}},
1118 { 'witness' => $wit->sigil.$self->ac_label, 'tokens' => \@ac_row } );
910a0a6d 1119 }
1120 }
1dd07bda 1121 $self->cached_table( $table );
1122 return $table;
910a0a6d 1123}
1124
1125sub _make_witness_row {
1dd07bda 1126 my( $path, $positions ) = @_;
910a0a6d 1127 my %char_hash;
1128 map { $char_hash{$_} = undef } @$positions;
2c669bca 1129 my $debug = 0;
910a0a6d 1130 foreach my $rdg ( @$path ) {
eca16057 1131 my $rtext = $rdg->text;
1132 $rtext = '#LACUNA#' if $rdg->is_lacuna;
2c669bca 1133 print STDERR "rank " . $rdg->rank . "\n" if $debug;
3a2ebbf4 1134 # print STDERR "No rank for " . $rdg->id . "\n" unless defined $rdg->rank;
1dd07bda 1135 $char_hash{$rdg->rank} = { 't' => $rdg };
910a0a6d 1136 }
1137 my @row = map { $char_hash{$_} } @$positions;
eca16057 1138 # Fill in lacuna markers for undef spots in the row
1139 my $last_el = shift @row;
1140 my @filled_row = ( $last_el );
1141 foreach my $el ( @row ) {
0e476982 1142 # If we are using node reference, make the lacuna node appear many times
1143 # in the table. If not, use the lacuna tag.
1dd07bda 1144 if( $last_el && $last_el->{'t'}->is_lacuna && !defined $el ) {
1145 $el = $last_el;
eca16057 1146 }
1147 push( @filled_row, $el );
1148 $last_el = $el;
1149 }
1150 return @filled_row;
910a0a6d 1151}
1152
4e5a7b2c 1153=head1 NAVIGATION METHODS
910a0a6d 1154
4e5a7b2c 1155=head2 reading_sequence( $first, $last, $sigil, $backup )
e2902068 1156
1157Returns the ordered list of readings, starting with $first and ending
4e5a7b2c 1158with $last, for the witness given in $sigil. If a $backup sigil is
1159specified (e.g. when walking a layered witness), it will be used wherever
1160no $sigil path exists. If there is a base text reading, that will be
1161used wherever no path exists for $sigil or $backup.
e2902068 1162
1163=cut
1164
910a0a6d 1165# TODO Think about returning some lazy-eval iterator.
b0b4421a 1166# TODO Get rid of backup; we should know from what witness is whether we need it.
910a0a6d 1167
e2902068 1168sub reading_sequence {
861c3e27 1169 my( $self, $start, $end, $witness ) = @_;
e2902068 1170
930ff666 1171 $witness = $self->baselabel unless $witness;
e2902068 1172 my @readings = ( $start );
1173 my %seen;
1174 my $n = $start;
3a2ebbf4 1175 while( $n && $n->id ne $end->id ) {
1176 if( exists( $seen{$n->id} ) ) {
63778331 1177 throw( "Detected loop for $witness at " . $n->id );
910a0a6d 1178 }
3a2ebbf4 1179 $seen{$n->id} = 1;
910a0a6d 1180
861c3e27 1181 my $next = $self->next_reading( $n, $witness );
44771cf2 1182 unless( $next ) {
63778331 1183 throw( "Did not find any path for $witness from reading " . $n->id );
44771cf2 1184 }
910a0a6d 1185 push( @readings, $next );
1186 $n = $next;
e2902068 1187 }
1188 # Check that the last reading is our end reading.
1189 my $last = $readings[$#readings];
63778331 1190 throw( "Last reading found from " . $start->text .
1191 " for witness $witness is not the end!" ) # TODO do we get this far?
3a2ebbf4 1192 unless $last->id eq $end->id;
e2902068 1193
1194 return @readings;
1195}
1196
4e5a7b2c 1197=head2 next_reading( $reading, $sigil );
8e1394aa 1198
4a8828f0 1199Returns the reading that follows the given reading along the given witness
930ff666 1200path.
8e1394aa 1201
1202=cut
1203
4a8828f0 1204sub next_reading {
e2902068 1205 # Return the successor via the corresponding path.
8e1394aa 1206 my $self = shift;
3a2ebbf4 1207 my $answer = $self->_find_linked_reading( 'next', @_ );
2c669bca 1208 return undef unless $answer;
3a2ebbf4 1209 return $self->reading( $answer );
8e1394aa 1210}
1211
4e5a7b2c 1212=head2 prior_reading( $reading, $sigil )
8e1394aa 1213
4a8828f0 1214Returns the reading that precedes the given reading along the given witness
930ff666 1215path.
8e1394aa 1216
1217=cut
1218
4a8828f0 1219sub prior_reading {
e2902068 1220 # Return the predecessor via the corresponding path.
8e1394aa 1221 my $self = shift;
3a2ebbf4 1222 my $answer = $self->_find_linked_reading( 'prior', @_ );
1223 return $self->reading( $answer );
8e1394aa 1224}
1225
4a8828f0 1226sub _find_linked_reading {
861c3e27 1227 my( $self, $direction, $node, $path ) = @_;
1228
1229 # Get a backup if we are dealing with a layered witness
1230 my $alt_path;
1231 my $aclabel = $self->ac_label;
1232 if( $path && $path =~ /^(.*)\Q$aclabel\E$/ ) {
1233 $alt_path = $1;
1234 }
1235
e2902068 1236 my @linked_paths = $direction eq 'next'
3a2ebbf4 1237 ? $self->sequence->edges_from( $node )
1238 : $self->sequence->edges_to( $node );
e2902068 1239 return undef unless scalar( @linked_paths );
8e1394aa 1240
e2902068 1241 # We have to find the linked path that contains all of the
1242 # witnesses supplied in $path.
1243 my( @path_wits, @alt_path_wits );
4e5a7b2c 1244 @path_wits = sort( $self->_witnesses_of_label( $path ) ) if $path;
1245 @alt_path_wits = sort( $self->_witnesses_of_label( $alt_path ) ) if $alt_path;
e2902068 1246 my $base_le;
1247 my $alt_le;
1248 foreach my $le ( @linked_paths ) {
3a2ebbf4 1249 if( $self->sequence->has_edge_attribute( @$le, $self->baselabel ) ) {
910a0a6d 1250 $base_le = $le;
910a0a6d 1251 }
508fd430 1252 my @le_wits = sort $self->path_witnesses( $le );
3a2ebbf4 1253 if( _is_within( \@path_wits, \@le_wits ) ) {
1254 # This is the right path.
1255 return $direction eq 'next' ? $le->[1] : $le->[0];
1256 } elsif( _is_within( \@alt_path_wits, \@le_wits ) ) {
1257 $alt_le = $le;
1258 }
8e1394aa 1259 }
e2902068 1260 # Got this far? Return the alternate path if it exists.
3a2ebbf4 1261 return $direction eq 'next' ? $alt_le->[1] : $alt_le->[0]
910a0a6d 1262 if $alt_le;
e2902068 1263
1264 # Got this far? Return the base path if it exists.
3a2ebbf4 1265 return $direction eq 'next' ? $base_le->[1] : $base_le->[0]
910a0a6d 1266 if $base_le;
e2902068 1267
1268 # Got this far? We have no appropriate path.
2c669bca 1269 warn "Could not find $direction node from " . $node->id
910a0a6d 1270 . " along path $path";
8e1394aa 1271 return undef;
1272}
1273
4a8828f0 1274# Some set logic.
1275sub _is_within {
1276 my( $set1, $set2 ) = @_;
7854e12e 1277 my $ret = @$set1; # will be 0, i.e. false, if set1 is empty
4a8828f0 1278 foreach my $el ( @$set1 ) {
910a0a6d 1279 $ret = 0 unless grep { /^\Q$el\E$/ } @$set2;
4a8828f0 1280 }
1281 return $ret;
1282}
1283
4e5a7b2c 1284# Return the string that joins together a list of witnesses for
1285# display on a single path.
1286sub _witnesses_of_label {
1287 my( $self, $label ) = @_;
1288 my $regex = $self->wit_list_separator;
1289 my @answer = split( /\Q$regex\E/, $label );
1290 return @answer;
b0b4421a 1291}
1292
d4b75f44 1293=head2 common_readings
1294
1295Returns the list of common readings in the graph (i.e. those readings that are
1296shared by all non-lacunose witnesses.)
1297
1298=cut
1299
1300sub common_readings {
1301 my $self = shift;
1302 my @common = grep { $_->is_common } $self->readings;
1303 return @common;
1304}
1305
b0b4421a 1306=head2 path_text( $sigil, $mainsigil [, $start, $end ] )
1307
1308Returns the text of a witness (plus its backup, if we are using a layer)
1309as stored in the collation. The text is returned as a string, where the
1310individual readings are joined with spaces and the meta-readings (e.g.
1311lacunae) are omitted. Optional specification of $start and $end allows
1312the generation of a subset of the witness text.
4e5a7b2c 1313
b0b4421a 1314=cut
1315
1316sub path_text {
861c3e27 1317 my( $self, $wit, $start, $end ) = @_;
b0b4421a 1318 $start = $self->start unless $start;
1319 $end = $self->end unless $end;
861c3e27 1320 my @path = grep { !$_->is_meta } $self->reading_sequence( $start, $end, $wit );
629e27b0 1321 my $pathtext = '';
1322 my $last;
1323 foreach my $r ( @path ) {
1324 if( $r->join_prior || !$last || $last->join_next ) {
1325 $pathtext .= $r->text;
1326 } else {
1327 $pathtext .= ' ' . $r->text;
1328 }
1329 $last = $r;
1330 }
1331 return $pathtext;
b0b4421a 1332}
4e5a7b2c 1333
1334=head1 INITIALIZATION METHODS
1335
1336These are mostly for use by parsers.
1337
1338=head2 make_witness_path( $witness )
1339
1340Link the array of readings contained in $witness->path (and in
1341$witness->uncorrected_path if it exists) into collation paths.
1342Clear out the arrays when finished.
de51424a 1343
4e5a7b2c 1344=head2 make_witness_paths
1345
1346Call make_witness_path for all witnesses in the tradition.
1347
1348=cut
930ff666 1349
7e450e44 1350# For use when a collation is constructed from a base text and an apparatus.
1351# We have the sequences of readings and just need to add path edges.
1f7aa795 1352# When we are done, clear out the witness path attributes, as they are no
1353# longer needed.
1354# TODO Find a way to replace the witness path attributes with encapsulated functions?
e2902068 1355
6a222840 1356sub make_witness_paths {
1357 my( $self ) = @_;
910a0a6d 1358 foreach my $wit ( $self->tradition->witnesses ) {
0068967c 1359 # print STDERR "Making path for " . $wit->sigil . "\n";
910a0a6d 1360 $self->make_witness_path( $wit );
7854e12e 1361 }
7854e12e 1362}
1363
6a222840 1364sub make_witness_path {
7854e12e 1365 my( $self, $wit ) = @_;
1366 my @chain = @{$wit->path};
15d2d3df 1367 my $sig = $wit->sigil;
7854e12e 1368 foreach my $idx ( 0 .. $#chain-1 ) {
910a0a6d 1369 $self->add_path( $chain[$idx], $chain[$idx+1], $sig );
7854e12e 1370 }
1f7aa795 1371 if( $wit->is_layered ) {
d9e873d0 1372 @chain = @{$wit->uncorrected_path};
1373 foreach my $idx( 0 .. $#chain-1 ) {
1374 my $source = $chain[$idx];
1375 my $target = $chain[$idx+1];
1376 $self->add_path( $source, $target, $sig.$self->ac_label )
1377 unless $self->has_path( $source, $target, $sig );
1378 }
15d2d3df 1379 }
1f7aa795 1380 $wit->clear_path;
1381 $wit->clear_uncorrected_path;
e2902068 1382}
1383
4e5a7b2c 1384=head2 calculate_ranks
1385
1386Calculate the reading ranks (that is, their aligned positions relative
1387to each other) for the graph. This can only be called on linear collations.
1388
b365fbae 1389=begin testing
1390
1391use Text::Tradition;
1392
1393my $cxfile = 't/data/Collatex-16.xml';
1394my $t = Text::Tradition->new(
1395 'name' => 'inline',
1396 'input' => 'CollateX',
1397 'file' => $cxfile,
1398 );
1399my $c = $t->collation;
1400
1401# Make an svg
bfcbcecb 1402my $table = $c->alignment_table;
1403ok( $c->has_cached_table, "Alignment table was cached" );
1404is( $c->alignment_table, $table, "Cached table returned upon second call" );
b365fbae 1405$c->calculate_ranks;
bfcbcecb 1406is( $c->alignment_table, $table, "Cached table retained with no rank change" );
b365fbae 1407$c->add_relationship( 'n9', 'n23', { 'type' => 'spelling' } );
bfcbcecb 1408isnt( $c->alignment_table, $table, "Alignment table changed after relationship add" );
b365fbae 1409
1410=end testing
1411
4e5a7b2c 1412=cut
1413
910a0a6d 1414sub calculate_ranks {
1415 my $self = shift;
b365fbae 1416 # Save the existing ranks, in case we need to invalidate the cached SVG.
1417 my %existing_ranks;
910a0a6d 1418 # Walk a version of the graph where every node linked by a relationship
1419 # edge is fundamentally the same node, and do a topological ranking on
1420 # the nodes in this graph.
c9bf3dbf 1421 my $topo_graph = Graph->new();
910a0a6d 1422 my %rel_containers;
1423 my $rel_ctr = 0;
1424 # Add the nodes
1425 foreach my $r ( $self->readings ) {
3a2ebbf4 1426 next if exists $rel_containers{$r->id};
910a0a6d 1427 my @rels = $r->related_readings( 'colocated' );
1428 if( @rels ) {
1429 # Make a relationship container.
1430 push( @rels, $r );
c9bf3dbf 1431 my $rn = 'rel_container_' . $rel_ctr++;
1432 $topo_graph->add_vertex( $rn );
910a0a6d 1433 foreach( @rels ) {
3a2ebbf4 1434 $rel_containers{$_->id} = $rn;
910a0a6d 1435 }
1436 } else {
1437 # Add a new node to mirror the old node.
3a2ebbf4 1438 $rel_containers{$r->id} = $r->id;
1439 $topo_graph->add_vertex( $r->id );
910a0a6d 1440 }
4a8828f0 1441 }
3a1f2523 1442
3a2ebbf4 1443 # Add the edges.
910a0a6d 1444 foreach my $r ( $self->readings ) {
b365fbae 1445 $existing_ranks{$r} = $r->rank;
3a2ebbf4 1446 foreach my $n ( $self->sequence->successors( $r->id ) ) {
1447 my( $tfrom, $tto ) = ( $rel_containers{$r->id},
1448 $rel_containers{$n} );
4e5a7b2c 1449 # $DB::single = 1 unless $tfrom && $tto;
3a2ebbf4 1450 $topo_graph->add_edge( $tfrom, $tto );
910a0a6d 1451 }
1452 }
1453
1454 # Now do the rankings, starting with the start node.
3a2ebbf4 1455 my $topo_start = $rel_containers{$self->start->id};
c9bf3dbf 1456 my $node_ranks = { $topo_start => 0 };
910a0a6d 1457 my @curr_origin = ( $topo_start );
1458 # A little iterative function.
1459 while( @curr_origin ) {
c9bf3dbf 1460 @curr_origin = _assign_rank( $topo_graph, $node_ranks, @curr_origin );
910a0a6d 1461 }
1462 # Transfer our rankings from the topological graph to the real one.
1463 foreach my $r ( $self->readings ) {
3a2ebbf4 1464 if( defined $node_ranks->{$rel_containers{$r->id}} ) {
1465 $r->rank( $node_ranks->{$rel_containers{$r->id}} );
67da8d6c 1466 } else {
63778331 1467 # Die. Find the last rank we calculated.
1468 my @all_defined = sort { $node_ranks->{$rel_containers{$a->id}}
1469 <=> $node_ranks->{$rel_containers{$b->id}} }
1470 $self->readings;
1471 my $last = pop @all_defined;
1472 throw( "Ranks not calculated after $last - do you have a cycle in the graph?" );
67da8d6c 1473 }
de51424a 1474 }
bfcbcecb 1475 # Do we need to invalidate the cached data?
1476 if( $self->has_cached_svg || $self->has_cached_table ) {
b365fbae 1477 foreach my $r ( $self->readings ) {
7c293912 1478 next if defined( $existing_ranks{$r} )
1479 && $existing_ranks{$r} == $r->rank;
c1915ab9 1480 # Something has changed, so clear the cache
bfcbcecb 1481 $self->_clear_cache;
c1915ab9 1482 # ...and recalculate the common readings.
1483 $self->calculate_common_readings();
b365fbae 1484 last;
1485 }
1486 }
c1915ab9 1487 # The graph calculation information is now up to date.
1488 $self->_graphcalc_done(1);
8e1394aa 1489}
3a1f2523 1490
910a0a6d 1491sub _assign_rank {
c9bf3dbf 1492 my( $graph, $node_ranks, @current_nodes ) = @_;
910a0a6d 1493 # Look at each of the children of @current_nodes. If all the child's
1494 # parents have a rank, assign it the highest rank + 1 and add it to
c9bf3dbf 1495 # @next_nodes. Otherwise skip it; we will return when the highest-ranked
1496 # parent gets a rank.
910a0a6d 1497 my @next_nodes;
1498 foreach my $c ( @current_nodes ) {
c9bf3dbf 1499 warn "Current reading $c has no rank!"
1500 unless exists $node_ranks->{$c};
1501 # print STDERR "Looking at child of node $c, rank "
1502 # . $node_ranks->{$c} . "\n";
1503 foreach my $child ( $graph->successors( $c ) ) {
1504 next if exists $node_ranks->{$child};
910a0a6d 1505 my $highest_rank = -1;
1506 my $skip = 0;
c9bf3dbf 1507 foreach my $parent ( $graph->predecessors( $child ) ) {
1508 if( exists $node_ranks->{$parent} ) {
1509 $highest_rank = $node_ranks->{$parent}
1510 if $highest_rank <= $node_ranks->{$parent};
910a0a6d 1511 } else {
1512 $skip = 1;
1513 last;
1514 }
1515 }
1516 next if $skip;
c9bf3dbf 1517 my $c_rank = $highest_rank + 1;
1518 # print STDERR "Assigning rank $c_rank to node $child \n";
1519 $node_ranks->{$child} = $c_rank;
910a0a6d 1520 push( @next_nodes, $child );
1521 }
1522 }
1523 return @next_nodes;
4cdd82f1 1524}
910a0a6d 1525
c1915ab9 1526sub _clear_cache {
1527 my $self = shift;
1528 $self->wipe_svg if $self->has_cached_svg;
1529 $self->wipe_table if $self->has_cached_table;
1530}
1531
1532
4e5a7b2c 1533=head2 flatten_ranks
1534
1535A convenience method for parsing collation data. Searches the graph for readings
1536with the same text at the same rank, and merges any that are found.
1537
1538=cut
1539
0e476982 1540sub flatten_ranks {
1541 my $self = shift;
1542 my %unique_rank_rdg;
bf6e338d 1543 my $changed;
0e476982 1544 foreach my $rdg ( $self->readings ) {
1545 next unless $rdg->has_rank;
1546 my $key = $rdg->rank . "||" . $rdg->text;
1547 if( exists $unique_rank_rdg{$key} ) {
1548 # Combine!
56eefa04 1549 # print STDERR "Combining readings at same rank: $key\n";
bf6e338d 1550 $changed = 1;
0e476982 1551 $self->merge_readings( $unique_rank_rdg{$key}, $rdg );
0ecb975c 1552 # TODO see if this now makes a common point.
0e476982 1553 } else {
1554 $unique_rank_rdg{$key} = $rdg;
1555 }
1556 }
bf6e338d 1557 # If we merged readings, the ranks are still fine but the alignment
1558 # table is wrong. Wipe it.
1559 $self->wipe_table() if $changed;
0e476982 1560}
4633f9e4 1561
1562
d4b75f44 1563=head2 calculate_common_readings
1564
1565Goes through the graph identifying the readings that appear in every witness
1566(apart from those with lacunae at that spot.) Marks them as common and returns
1567the list.
1568
1569=begin testing
1570
1571use Text::Tradition;
1572
1573my $cxfile = 't/data/Collatex-16.xml';
1574my $t = Text::Tradition->new(
1575 'name' => 'inline',
1576 'input' => 'CollateX',
1577 'file' => $cxfile,
1578 );
1579my $c = $t->collation;
1580
1581my @common = $c->calculate_common_readings();
1582is( scalar @common, 8, "Found correct number of common readings" );
1583my @marked = sort $c->common_readings();
1584is( scalar @common, 8, "All common readings got marked as such" );
1585my @expected = qw/ n1 n12 n16 n19 n20 n5 n6 n7 /;
1586is_deeply( \@marked, \@expected, "Found correct list of common readings" );
1587
1588=end testing
1589
1590=cut
1591
1592sub calculate_common_readings {
1593 my $self = shift;
1594 my @common;
c1915ab9 1595 map { $_->is_common( 0 ) } $self->readings;
1596 # Implicitly calls calculate_ranks
1dd07bda 1597 my $table = $self->alignment_table;
d4b75f44 1598 foreach my $idx ( 0 .. $table->{'length'} - 1 ) {
7f52eac8 1599 my @row = map { $_->{'tokens'}->[$idx]
1600 ? $_->{'tokens'}->[$idx]->{'t'} : '' }
1601 @{$table->{'alignment'}};
d4b75f44 1602 my %hash;
1603 foreach my $r ( @row ) {
1604 if( $r ) {
1605 $hash{$r->id} = $r unless $r->is_meta;
1606 } else {
1607 $hash{'UNDEF'} = $r;
1608 }
1609 }
1610 if( keys %hash == 1 && !exists $hash{'UNDEF'} ) {
1611 my( $r ) = values %hash;
1612 $r->is_common( 1 );
1613 push( @common, $r );
1614 }
1615 }
1616 return @common;
1617}
1618
861c3e27 1619=head2 text_from_paths
1620
1621Calculate the text array for all witnesses from the path, for later consistency
1622checking. Only to be used if there is no non-graph-based way to know the
1623original texts.
1624
1625=cut
1626
1627sub text_from_paths {
1628 my $self = shift;
1629 foreach my $wit ( $self->tradition->witnesses ) {
5164a6f0 1630 my @readings = $self->reading_sequence( $self->start, $self->end, $wit->sigil );
1631 my @text;
1632 foreach my $r ( @readings ) {
1633 next if $r->is_meta;
1634 push( @text, $r->text );
1635 }
861c3e27 1636 $wit->text( \@text );
1637 if( $wit->is_layered ) {
5164a6f0 1638 my @ucrdgs = $self->reading_sequence( $self->start, $self->end,
1639 $wit->sigil.$self->ac_label );
1640 my @uctext;
1641 foreach my $r ( @ucrdgs ) {
1642 next if $r->is_meta;
1643 push( @uctext, $r->text );
1644 }
1645 $wit->layertext( \@uctext );
861c3e27 1646 }
1647 }
1648}
0e476982 1649
4e5a7b2c 1650=head1 UTILITY FUNCTIONS
1651
1652=head2 common_predecessor( $reading_a, $reading_b )
8e1394aa 1653
4e5a7b2c 1654Find the last reading that occurs in sequence before both the given readings.
1655
1656=head2 common_successor( $reading_a, $reading_b )
1657
1658Find the first reading that occurs in sequence after both the given readings.
1659
22222af9 1660=begin testing
1661
1662use Text::Tradition;
1663
1664my $cxfile = 't/data/Collatex-16.xml';
1665my $t = Text::Tradition->new(
1666 'name' => 'inline',
1667 'input' => 'CollateX',
1668 'file' => $cxfile,
1669 );
1670my $c = $t->collation;
1671
4e5a7b2c 1672is( $c->common_predecessor( 'n9', 'n23' )->id,
22222af9 1673 'n20', "Found correct common predecessor" );
4e5a7b2c 1674is( $c->common_successor( 'n9', 'n23' )->id,
22222af9 1675 '#END#', "Found correct common successor" );
1676
4e5a7b2c 1677is( $c->common_predecessor( 'n19', 'n17' )->id,
22222af9 1678 'n16', "Found correct common predecessor for readings on same path" );
4e5a7b2c 1679is( $c->common_successor( 'n21', 'n26' )->id,
22222af9 1680 '#END#', "Found correct common successor for readings on same path" );
1681
1682=end testing
1683
1684=cut
1685
1686## Return the closest reading that is a predecessor of both the given readings.
1687sub common_predecessor {
1688 my $self = shift;
4e5a7b2c 1689 my( $r1, $r2 ) = $self->_objectify_args( @_ );
027d819c 1690 return $self->_common_in_path( $r1, $r2, 'predecessors' );
22222af9 1691}
1692
1693sub common_successor {
1694 my $self = shift;
4e5a7b2c 1695 my( $r1, $r2 ) = $self->_objectify_args( @_ );
027d819c 1696 return $self->_common_in_path( $r1, $r2, 'successors' );
22222af9 1697}
1698
027d819c 1699sub _common_in_path {
22222af9 1700 my( $self, $r1, $r2, $dir ) = @_;
1701 my $iter = $r1->rank > $r2->rank ? $r1->rank : $r2->rank;
1702 $iter = $self->end->rank - $iter if $dir eq 'successors';
1703 my @candidates;
1704 my @last_checked = ( $r1, $r2 );
1705 my %all_seen;
1706 while( !@candidates ) {
1707 my @new_lc;
1708 foreach my $lc ( @last_checked ) {
1709 foreach my $p ( $lc->$dir ) {
1710 if( $all_seen{$p->id} ) {
1711 push( @candidates, $p );
1712 } else {
1713 $all_seen{$p->id} = 1;
1714 push( @new_lc, $p );
1715 }
1716 }
1717 }
1718 @last_checked = @new_lc;
1719 }
1720 my @answer = sort { $a->rank <=> $b->rank } @candidates;
1721 return $dir eq 'predecessors' ? pop( @answer ) : shift ( @answer );
1722}
1723
63778331 1724sub throw {
1725 Text::Tradition::Error->throw(
1726 'ident' => 'Collation error',
1727 'message' => $_[0],
1728 );
1729}
1730
dd3b58b0 1731no Moose;
1732__PACKAGE__->meta->make_immutable;
e867486f 1733
027d819c 1734=head1 LICENSE
e867486f 1735
027d819c 1736This package is free software and is provided "as is" without express
1737or implied warranty. You can redistribute it and/or modify it under
1738the same terms as Perl itself.
e867486f 1739
027d819c 1740=head1 AUTHOR
e867486f 1741
027d819c 1742Tara L Andrews E<lt>aurum@cpan.orgE<gt>