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