022c2bc4e413d684f57444e18b2ac305a5171964
[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         say STDERR "rank " . $rdg->rank if $debug;
1285         # say STDERR "No rank for " . $rdg->id unless defined $rdg->rank;
1286         $char_hash{$rdg->rank} = { 't' => $rdg };
1287     }
1288     my @row = map { $char_hash{$_} } @$positions;
1289     # Fill in lacuna markers for undef spots in the row
1290     my $last_el = shift @row;
1291     my @filled_row = ( $last_el );
1292     foreach my $el ( @row ) {
1293         # If we are using node reference, make the lacuna node appear many times
1294         # in the table.  If not, use the lacuna tag.
1295         if( $last_el && $last_el->{'t'}->is_lacuna && !defined $el ) {
1296             $el = $last_el;
1297         }
1298         push( @filled_row, $el );
1299         $last_el = $el;
1300     }
1301     return @filled_row;
1302 }
1303
1304 =head1 NAVIGATION METHODS
1305
1306 =head2 reading_sequence( $first, $last, $sigil, $backup )
1307
1308 Returns the ordered list of readings, starting with $first and ending
1309 with $last, for the witness given in $sigil. If a $backup sigil is 
1310 specified (e.g. when walking a layered witness), it will be used wherever
1311 no $sigil path exists.  If there is a base text reading, that will be
1312 used wherever no path exists for $sigil or $backup.
1313
1314 =cut
1315
1316 # TODO Think about returning some lazy-eval iterator.
1317 # TODO Get rid of backup; we should know from what witness is whether we need it.
1318
1319 sub reading_sequence {
1320     my( $self, $start, $end, $witness ) = @_;
1321
1322     $witness = $self->baselabel unless $witness;
1323     my @readings = ( $start );
1324     my %seen;
1325     my $n = $start;
1326     while( $n && $n->id ne $end->id ) {
1327         if( exists( $seen{$n->id} ) ) {
1328             throw( "Detected loop for $witness at " . $n->id );
1329         }
1330         $seen{$n->id} = 1;
1331         
1332         my $next = $self->next_reading( $n, $witness );
1333         unless( $next ) {
1334             throw( "Did not find any path for $witness from reading " . $n->id );
1335         }
1336         push( @readings, $next );
1337         $n = $next;
1338     }
1339     # Check that the last reading is our end reading.
1340     my $last = $readings[$#readings];
1341     throw( "Last reading found from " . $start->text .
1342         " for witness $witness is not the end!" ) # TODO do we get this far?
1343         unless $last->id eq $end->id;
1344     
1345     return @readings;
1346 }
1347
1348 =head2 next_reading( $reading, $sigil );
1349
1350 Returns the reading that follows the given reading along the given witness
1351 path.  
1352
1353 =cut
1354
1355 sub next_reading {
1356     # Return the successor via the corresponding path.
1357     my $self = shift;
1358     my $answer = $self->_find_linked_reading( 'next', @_ );
1359         return undef unless $answer;
1360     return $self->reading( $answer );
1361 }
1362
1363 =head2 prior_reading( $reading, $sigil )
1364
1365 Returns the reading that precedes the given reading along the given witness
1366 path.  
1367
1368 =cut
1369
1370 sub prior_reading {
1371     # Return the predecessor via the corresponding path.
1372     my $self = shift;
1373     my $answer = $self->_find_linked_reading( 'prior', @_ );
1374     return $self->reading( $answer );
1375 }
1376
1377 sub _find_linked_reading {
1378     my( $self, $direction, $node, $path ) = @_;
1379     
1380     # Get a backup if we are dealing with a layered witness
1381     my $alt_path;
1382     my $aclabel = $self->ac_label;
1383     if( $path && $path =~ /^(.*)\Q$aclabel\E$/ ) {
1384         $alt_path = $1;
1385     }
1386     
1387     my @linked_paths = $direction eq 'next' 
1388         ? $self->sequence->edges_from( $node ) 
1389         : $self->sequence->edges_to( $node );
1390     return undef unless scalar( @linked_paths );
1391     
1392     # We have to find the linked path that contains all of the
1393     # witnesses supplied in $path.
1394     my( @path_wits, @alt_path_wits );
1395     @path_wits = sort( $self->_witnesses_of_label( $path ) ) if $path;
1396     @alt_path_wits = sort( $self->_witnesses_of_label( $alt_path ) ) if $alt_path;
1397     my $base_le;
1398     my $alt_le;
1399     foreach my $le ( @linked_paths ) {
1400         if( $self->sequence->has_edge_attribute( @$le, $self->baselabel ) ) {
1401             $base_le = $le;
1402         }
1403                 my @le_wits = sort $self->path_witnesses( $le );
1404                 if( _is_within( \@path_wits, \@le_wits ) ) {
1405                         # This is the right path.
1406                         return $direction eq 'next' ? $le->[1] : $le->[0];
1407                 } elsif( _is_within( \@alt_path_wits, \@le_wits ) ) {
1408                         $alt_le = $le;
1409                 }
1410     }
1411     # Got this far? Return the alternate path if it exists.
1412     return $direction eq 'next' ? $alt_le->[1] : $alt_le->[0]
1413         if $alt_le;
1414
1415     # Got this far? Return the base path if it exists.
1416     return $direction eq 'next' ? $base_le->[1] : $base_le->[0]
1417         if $base_le;
1418
1419     # Got this far? We have no appropriate path.
1420     warn "Could not find $direction node from " . $node->id 
1421         . " along path $path";
1422     return undef;
1423 }
1424
1425 # Some set logic.
1426 sub _is_within {
1427     my( $set1, $set2 ) = @_;
1428     my $ret = @$set1; # will be 0, i.e. false, if set1 is empty
1429     foreach my $el ( @$set1 ) {
1430         $ret = 0 unless grep { /^\Q$el\E$/ } @$set2;
1431     }
1432     return $ret;
1433 }
1434
1435 # Return the string that joins together a list of witnesses for
1436 # display on a single path.
1437 sub _witnesses_of_label {
1438     my( $self, $label ) = @_;
1439     my $regex = $self->wit_list_separator;
1440     my @answer = split( /\Q$regex\E/, $label );
1441     return @answer;
1442 }
1443
1444 =head2 common_readings
1445
1446 Returns the list of common readings in the graph (i.e. those readings that are
1447 shared by all non-lacunose witnesses.)
1448
1449 =cut
1450
1451 sub common_readings {
1452         my $self = shift;
1453         my @common = grep { $_->is_common } $self->readings;
1454         return @common;
1455 }
1456
1457 =head2 path_text( $sigil, [, $start, $end ] )
1458
1459 Returns the text of a witness (plus its backup, if we are using a layer)
1460 as stored in the collation.  The text is returned as a string, where the
1461 individual readings are joined with spaces and the meta-readings (e.g.
1462 lacunae) are omitted.  Optional specification of $start and $end allows
1463 the generation of a subset of the witness text.
1464
1465 =cut
1466
1467 sub path_text {
1468         my( $self, $wit, $start, $end ) = @_;
1469         $start = $self->start unless $start;
1470         $end = $self->end unless $end;
1471         my @path = grep { !$_->is_meta } $self->reading_sequence( $start, $end, $wit );
1472         my $pathtext = '';
1473         my $last;
1474         foreach my $r ( @path ) {
1475                 unless ( $r->join_prior || !$last || $last->join_next ) {
1476                         $pathtext .= ' ';
1477                 } 
1478                 $pathtext .= $r->text;
1479                 $last = $r;
1480         }
1481         return $pathtext;
1482 }
1483
1484 =head1 INITIALIZATION METHODS
1485
1486 These are mostly for use by parsers.
1487
1488 =head2 make_witness_path( $witness )
1489
1490 Link the array of readings contained in $witness->path (and in 
1491 $witness->uncorrected_path if it exists) into collation paths.
1492 Clear out the arrays when finished.
1493
1494 =head2 make_witness_paths
1495
1496 Call make_witness_path for all witnesses in the tradition.
1497
1498 =cut
1499
1500 # For use when a collation is constructed from a base text and an apparatus.
1501 # We have the sequences of readings and just need to add path edges.
1502 # When we are done, clear out the witness path attributes, as they are no
1503 # longer needed.
1504 # TODO Find a way to replace the witness path attributes with encapsulated functions?
1505
1506 sub make_witness_paths {
1507     my( $self ) = @_;
1508     foreach my $wit ( $self->tradition->witnesses ) {
1509         # say STDERR "Making path for " . $wit->sigil;
1510         $self->make_witness_path( $wit );
1511     }
1512 }
1513
1514 sub make_witness_path {
1515     my( $self, $wit ) = @_;
1516     my @chain = @{$wit->path};
1517     my $sig = $wit->sigil;
1518     # Add start and end if necessary
1519     unshift( @chain, $self->start ) unless $chain[0] eq $self->start;
1520     push( @chain, $self->end ) unless $chain[-1] eq $self->end;
1521     foreach my $idx ( 0 .. $#chain-1 ) {
1522         $self->add_path( $chain[$idx], $chain[$idx+1], $sig );
1523     }
1524     if( $wit->is_layered ) {
1525         @chain = @{$wit->uncorrected_path};
1526                 unshift( @chain, $self->start ) unless $chain[0] eq $self->start;
1527                 push( @chain, $self->end ) unless $chain[-1] eq $self->end;
1528         foreach my $idx( 0 .. $#chain-1 ) {
1529             my $source = $chain[$idx];
1530             my $target = $chain[$idx+1];
1531             $self->add_path( $source, $target, $sig.$self->ac_label )
1532                 unless $self->has_path( $source, $target, $sig );
1533         }
1534     }
1535     $wit->clear_path;
1536     $wit->clear_uncorrected_path;
1537 }
1538
1539 =head2 calculate_ranks
1540
1541 Calculate the reading ranks (that is, their aligned positions relative
1542 to each other) for the graph.  This can only be called on linear collations.
1543
1544 =begin testing
1545
1546 use Text::Tradition;
1547
1548 my $cxfile = 't/data/Collatex-16.xml';
1549 my $t = Text::Tradition->new( 
1550     'name'  => 'inline', 
1551     'input' => 'CollateX',
1552     'file'  => $cxfile,
1553     );
1554 my $c = $t->collation;
1555
1556 # Make an svg
1557 my $table = $c->alignment_table;
1558 ok( $c->has_cached_table, "Alignment table was cached" );
1559 is( $c->alignment_table, $table, "Cached table returned upon second call" );
1560 $c->calculate_ranks;
1561 is( $c->alignment_table, $table, "Cached table retained with no rank change" );
1562 $c->add_relationship( 'n24', 'n23', { 'type' => 'spelling' } );
1563 isnt( $c->alignment_table, $table, "Alignment table changed after relationship add" );
1564
1565 =end testing
1566
1567 =cut
1568
1569 sub calculate_ranks {
1570     my $self = shift;
1571     # Save the existing ranks, in case we need to invalidate the cached SVG.
1572     my %existing_ranks;
1573     map { $existing_ranks{$_} = $_->rank } $self->readings;
1574
1575     # Do the rankings based on the relationship equivalence graph, starting 
1576     # with the start node.
1577     my ( $node_ranks, $rank_nodes ) = $self->relations->equivalence_ranks();
1578
1579     # Transfer our rankings from the topological graph to the real one.
1580     foreach my $r ( $self->readings ) {
1581         if( defined $node_ranks->{$self->equivalence( $r->id )} ) {
1582             $r->rank( $node_ranks->{$self->equivalence( $r->id )} );
1583         } else {
1584                 # Die. Find the last rank we calculated.
1585                 my @all_defined = sort { ( $node_ranks->{$self->equivalence( $a->id )}||-1 )
1586                                  <=> ( $node_ranks->{$self->equivalence( $b->id )}||-1 ) }
1587                         $self->readings;
1588                 my $last = pop @all_defined;
1589             throw( "Ranks not calculated after $last - do you have a cycle in the graph?" );
1590         }
1591     }
1592     # Do we need to invalidate the cached data?
1593     if( $self->has_cached_table ) {
1594         foreach my $r ( $self->readings ) {
1595                 next if defined( $existing_ranks{$r} ) 
1596                         && $existing_ranks{$r} == $r->rank;
1597                 # Something has changed, so clear the cache
1598                 $self->_clear_cache;
1599                         # ...and recalculate the common readings.
1600                         $self->calculate_common_readings();
1601                 last;
1602         }
1603     }
1604         # The graph calculation information is now up to date.
1605         $self->_graphcalc_done(1);
1606 }
1607
1608 sub _clear_cache {
1609         my $self = shift;
1610         $self->wipe_table if $self->has_cached_table;
1611 }       
1612
1613
1614 =head2 flatten_ranks
1615
1616 A convenience method for parsing collation data.  Searches the graph for readings
1617 with the same text at the same rank, and merges any that are found.
1618
1619 =cut
1620
1621 sub flatten_ranks {
1622     my $self = shift;
1623     my %unique_rank_rdg;
1624     my $changed;
1625     foreach my $rdg ( $self->readings ) {
1626         next unless $rdg->has_rank;
1627         my $key = $rdg->rank . "||" . $rdg->text;
1628         if( exists $unique_rank_rdg{$key} ) {
1629                 # Make sure they don't have different grammatical forms
1630                         my $ur = $unique_rank_rdg{$key};
1631                         if( $rdg->disambiguated && $ur->disambiguated ) {
1632                                 my $rform = join( '//', map { $_->form->to_string } $rdg->lexemes );
1633                                 my $uform = join( '//', map { $_->form->to_string } $ur->lexemes );
1634                                 next unless $rform eq $uform;
1635                         } elsif( $rdg->disambiguated xor $ur->disambiguated ) {
1636                                 next;
1637                         }
1638             # Combine!
1639                 #say STDERR "Combining readings at same rank: $key";
1640                 $changed = 1;
1641             $self->merge_readings( $unique_rank_rdg{$key}, $rdg );
1642             # TODO see if this now makes a common point.
1643         } else {
1644             $unique_rank_rdg{$key} = $rdg;
1645         }
1646     }
1647     # If we merged readings, the ranks are still fine but the alignment
1648     # table is wrong. Wipe it.
1649     $self->wipe_table() if $changed;
1650 }
1651         
1652
1653 =head2 calculate_common_readings
1654
1655 Goes through the graph identifying the readings that appear in every witness 
1656 (apart from those with lacunae at that spot.) Marks them as common and returns
1657 the list.
1658
1659 =begin testing
1660
1661 use Text::Tradition;
1662
1663 my $cxfile = 't/data/Collatex-16.xml';
1664 my $t = Text::Tradition->new( 
1665     'name'  => 'inline', 
1666     'input' => 'CollateX',
1667     'file'  => $cxfile,
1668     );
1669 my $c = $t->collation;
1670
1671 my @common = $c->calculate_common_readings();
1672 is( scalar @common, 8, "Found correct number of common readings" );
1673 my @marked = sort $c->common_readings();
1674 is( scalar @common, 8, "All common readings got marked as such" );
1675 my @expected = qw/ n1 n11 n16 n19 n20 n5 n6 n7 /;
1676 is_deeply( \@marked, \@expected, "Found correct list of common readings" );
1677
1678 =end testing
1679
1680 =cut
1681
1682 sub calculate_common_readings {
1683         my $self = shift;
1684         my @common;
1685         map { $_->is_common( 0 ) } $self->readings;
1686         # Implicitly calls calculate_ranks
1687         my $table = $self->alignment_table;
1688         foreach my $idx ( 0 .. $table->{'length'} - 1 ) {
1689                 my @row = map { $_->{'tokens'}->[$idx] 
1690                                                         ? $_->{'tokens'}->[$idx]->{'t'} : '' } 
1691                                         @{$table->{'alignment'}};
1692                 my %hash;
1693                 foreach my $r ( @row ) {
1694                         if( $r ) {
1695                                 $hash{$r->id} = $r unless $r->is_meta;
1696                         } else {
1697                                 $hash{'UNDEF'} = $r;
1698                         }
1699                 }
1700                 if( keys %hash == 1 && !exists $hash{'UNDEF'} ) {
1701                         my( $r ) = values %hash;
1702                         $r->is_common( 1 );
1703                         push( @common, $r );
1704                 }
1705         }
1706         return @common;
1707 }
1708
1709 =head2 text_from_paths
1710
1711 Calculate the text array for all witnesses from the path, for later consistency
1712 checking.  Only to be used if there is no non-graph-based way to know the
1713 original texts.
1714
1715 =cut
1716
1717 sub text_from_paths {
1718         my $self = shift;
1719     foreach my $wit ( $self->tradition->witnesses ) {
1720         my @readings = $self->reading_sequence( $self->start, $self->end, $wit->sigil );
1721         my @text;
1722         foreach my $r ( @readings ) {
1723                 next if $r->is_meta;
1724                 push( @text, $r->text );
1725         }
1726         $wit->text( \@text );
1727         if( $wit->is_layered ) {
1728                         my @ucrdgs = $self->reading_sequence( $self->start, $self->end, 
1729                                                                                                   $wit->sigil.$self->ac_label );
1730                         my @uctext;
1731                         foreach my $r ( @ucrdgs ) {
1732                                 next if $r->is_meta;
1733                                 push( @uctext, $r->text );
1734                         }
1735                         $wit->layertext( \@uctext );
1736         }
1737     }    
1738 }
1739
1740 =head1 UTILITY FUNCTIONS
1741
1742 =head2 common_predecessor( $reading_a, $reading_b )
1743
1744 Find the last reading that occurs in sequence before both the given readings.
1745 At the very least this should be $self->start.
1746
1747 =head2 common_successor( $reading_a, $reading_b )
1748
1749 Find the first reading that occurs in sequence after both the given readings.
1750 At the very least this should be $self->end.
1751     
1752 =begin testing
1753
1754 use Text::Tradition;
1755
1756 my $cxfile = 't/data/Collatex-16.xml';
1757 my $t = Text::Tradition->new( 
1758     'name'  => 'inline', 
1759     'input' => 'CollateX',
1760     'file'  => $cxfile,
1761     );
1762 my $c = $t->collation;
1763
1764 is( $c->common_predecessor( 'n24', 'n23' )->id, 
1765     'n20', "Found correct common predecessor" );
1766 is( $c->common_successor( 'n24', 'n23' )->id, 
1767     '__END__', "Found correct common successor" );
1768
1769 is( $c->common_predecessor( 'n19', 'n17' )->id, 
1770     'n16', "Found correct common predecessor for readings on same path" );
1771 is( $c->common_successor( 'n21', 'n10' )->id, 
1772     '__END__', "Found correct common successor for readings on same path" );
1773
1774 =end testing
1775
1776 =cut
1777
1778 ## Return the closest reading that is a predecessor of both the given readings.
1779 sub common_predecessor {
1780         my $self = shift;
1781         my( $r1, $r2 ) = $self->_objectify_args( @_ );
1782         return $self->_common_in_path( $r1, $r2, 'predecessors' );
1783 }
1784
1785 sub common_successor {
1786         my $self = shift;
1787         my( $r1, $r2 ) = $self->_objectify_args( @_ );
1788         return $self->_common_in_path( $r1, $r2, 'successors' );
1789 }
1790
1791
1792 # TODO think about how to do this without ranks...
1793 sub _common_in_path {
1794         my( $self, $r1, $r2, $dir ) = @_;
1795         my $iter = $self->end->rank;
1796         my @candidates;
1797         my @last_r1 = ( $r1 );
1798         my @last_r2 = ( $r2 );
1799         # my %all_seen = ( $r1 => 'r1', $r2 => 'r2' );
1800         my %all_seen;
1801         # say STDERR "Finding common $dir for $r1, $r2";
1802         while( !@candidates ) {
1803                 last unless $iter--;  # Avoid looping infinitely
1804                 # Iterate separately down the graph from r1 and r2
1805                 my( @new_lc1, @new_lc2 );
1806                 foreach my $lc ( @last_r1 ) {
1807                         foreach my $p ( $lc->$dir ) {
1808                                 if( $all_seen{$p->id} && $all_seen{$p->id} ne 'r1' ) {
1809                                         # say STDERR "Path candidate $p from $lc";
1810                                         push( @candidates, $p );
1811                                 } elsif( !$all_seen{$p->id} ) {
1812                                         $all_seen{$p->id} = 'r1';
1813                                         push( @new_lc1, $p );
1814                                 }
1815                         }
1816                 }
1817                 foreach my $lc ( @last_r2 ) {
1818                         foreach my $p ( $lc->$dir ) {
1819                                 if( $all_seen{$p->id} && $all_seen{$p->id} ne 'r2' ) {
1820                                         # say STDERR "Path candidate $p from $lc";
1821                                         push( @candidates, $p );
1822                                 } elsif( !$all_seen{$p->id} ) {
1823                                         $all_seen{$p->id} = 'r2';
1824                                         push( @new_lc2, $p );
1825                                 }
1826                         }
1827                 }
1828                 @last_r1 = @new_lc1;
1829                 @last_r2 = @new_lc2;
1830         }
1831         my @answer = sort { $a->rank <=> $b->rank } @candidates;
1832         return $dir eq 'predecessors' ? pop( @answer ) : shift ( @answer );
1833 }
1834
1835 sub throw {
1836         Text::Tradition::Error->throw( 
1837                 'ident' => 'Collation error',
1838                 'message' => $_[0],
1839                 );
1840 }
1841
1842 no Moose;
1843 __PACKAGE__->meta->make_immutable;
1844
1845 =head1 LICENSE
1846
1847 This package is free software and is provided "as is" without express
1848 or implied warranty.  You can redistribute it and/or modify it under
1849 the same terms as Perl itself.
1850
1851 =head1 AUTHOR
1852
1853 Tara L Andrews E<lt>aurum@cpan.orgE<gt>