| blib/lib/Text/TEI/Markup.pm | |||
|---|---|---|---|
| Criterion | Covered | Total | % |
| statement | 13 | 15 | 86.6 |
| branch | n/a | ||
| condition | n/a | ||
| subroutine | 5 | 5 | 100.0 |
| pod | n/a | ||
| total | 18 | 20 | 90.0 |
| line | stmt | bran | cond | sub | pod | time | code | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | package Text::TEI::Markup; | |||||||||||||
| 2 | ||||||||||||||
| 3 | 1 | 1 | 48448 | use strict; | ||||||||||
| 1 | 2 | |||||||||||||
| 1 | 36 | |||||||||||||
| 4 | 1 | 1 | 4 | use vars qw( $VERSION @EXPORT_OK ); | ||||||||||
| 1 | 1 | |||||||||||||
| 1 | 45 | |||||||||||||
| 5 | 1 | 1 | 5 | use Encode; | ||||||||||
| 1 | 2 | |||||||||||||
| 1 | 80 | |||||||||||||
| 6 | 1 | 1 | 4 | use Exporter 'import'; | ||||||||||
| 1 | 1 | |||||||||||||
| 1 | 20 | |||||||||||||
| 7 | 1 | 1 | 1621 | use XML::LibXML; | ||||||||||
| 0 | ||||||||||||||
| 0 | ||||||||||||||
| 8 | ||||||||||||||
| 9 | use utf8; | |||||||||||||
| 10 | ||||||||||||||
| 11 | $VERSION = '1.9'; | |||||||||||||
| 12 | @EXPORT_OK = qw( &to_xml &word_tag_wrap ); | |||||||||||||
| 13 | ||||||||||||||
| 14 | =head1 NAME | |||||||||||||
| 15 | ||||||||||||||
| 16 | Text::TEI::Markup - a transcription markup syntax for TEI XML | |||||||||||||
| 17 | ||||||||||||||
| 18 | =head1 SYNOPSIS | |||||||||||||
| 19 | ||||||||||||||
| 20 | use Text::TEI::Markup qw( to_xml ); | |||||||||||||
| 21 | my $xml_string = to_xml( file => $markup_file, | |||||||||||||
| 22 | template => $template_xml_string, | |||||||||||||
| 23 | %opts ); # see below for available options | |||||||||||||
| 24 | ||||||||||||||
| 25 | use Text::TEI::Markup qw( word_tag_wrap ); | |||||||||||||
| 26 | my $word_wrapped_xml = word_tag_wrap( $tei_xml_string ); | |||||||||||||
| 27 | ||||||||||||||
| 28 | =head1 DESCRIPTION | |||||||||||||
| 29 | ||||||||||||||
| 30 | TEI XML is a wonderful thing. The elements defined therein allow a | |||||||||||||
| 31 | transcriber to record and represent just about any feature of a text that | |||||||||||||
| 32 | he or she encounters. | |||||||||||||
| 33 | ||||||||||||||
| 34 | The problem is the transcription itself. When I am transcribing a | |||||||||||||
| 35 | manuscript, especially if that manuscript is in a bunch of funny characters | |||||||||||||
| 36 | on the keymap for another language, I do not want to be switching back and | |||||||||||||
| 37 | forth between keyboard layouts in order to type " | |||||||||||||
| 38 | arrow-arrow-arrow-arrow-arrow " every six seconds. It's prone to | |||||||||||||
| 39 | typo, it's astonishingly slow, and it makes my wrists hurt just to think | |||||||||||||
| 40 | about it. I also don't really want to fire up an XML editor, select the | |||||||||||||
| 41 | words or characters that need to be tagged, and click a lot. That way is | |||||||||||||
| 42 | not prone to typo, but it's still pretty darn slow, and it makes my wrists | |||||||||||||
| 43 | hurt B |
|||||||||||||
| 44 | ||||||||||||||
| 45 | Text::TEI::Markup is my solution to that problem. It defines a bunch of | |||||||||||||
| 46 | single- or double-character sigils that represent tags. These are a lot | |||||||||||||
| 47 | faster and easier to type; I don't have to worry about typos; and I can do | |||||||||||||
| 48 | it all with a plain text editor, thus minimizing use of the mouse. | |||||||||||||
| 49 | ||||||||||||||
| 50 | I have tried to pick sigils that don't conflict with characters that are | |||||||||||||
| 51 | found in manuscripts. I have succeeded for my particular set of | |||||||||||||
| 52 | manuscripts, but I have not succeeded for the general case. If you like the | |||||||||||||
| 53 | idea behind this module, you are still almost guaranteed to hate the sigils | |||||||||||||
| 54 | I've picked. That's okay; you can re-define them. | |||||||||||||
| 55 | ||||||||||||||
| 56 | =head2 Extra bonus solution: word wrapping with |
|||||||||||||
| 57 | ||||||||||||||
| 58 | Even if you are happy as a clam in the graphical XML editor of your choice, | |||||||||||||
| 59 | this module exports a function that may be useful to you. The TEI P5 | |||||||||||||
| 60 | guidelines include a module called "analysis", which allows the user to tag | |||||||||||||
| 61 | sentences, clauses, words, morphemes, or any other sort of semantic segment | |||||||||||||
| 62 | of a text. This is really good for programmatic applications, but very | |||||||||||||
| 63 | boring and repetitive to have to tag. | |||||||||||||
| 64 | ||||||||||||||
| 65 | The function B |
|||||||||||||
| 66 | an XML string as input, looks for words (defined by whitespace separation) | |||||||||||||
| 67 | and returns an XML string with each of these words wrapped in an | |||||||||||||
| 68 | appropriate tag. If the word has complex elements (e.g. editorial | |||||||||||||
| 69 | expansion), it will be wrapped in a |
be in a simple |
||||||||||||
| 71 | words, as long as there is no trailing whitespace before the (or |
|||||||||||||
| 72 | |
|||||||||||||
| 73 | return. | |||||||||||||
| 74 | ||||||||||||||
| 75 | =head1 MARKUP SYNTAX | |||||||||||||
| 76 | ||||||||||||||
| 77 | The input file has a header and a body. The header begins with a '=HEAD' | |||||||||||||
| 78 | tag, and consists of a colon-separated list of key_value pairs. These keys, | |||||||||||||
| 79 | which are case insensitive, get directly substituted into an XML template; | |||||||||||||
| 80 | the idea is that your TEI header won't change very much between files, so | |||||||||||||
| 81 | you write it once with template values, pass it to &to_xml, and the | |||||||||||||
| 82 | substitution happens as if by magic. The keyword /MAIN/i is reserved for | |||||||||||||
| 83 | the content between the tags - that is, all the content that | |||||||||||||
| 84 | will be generated after the '=BODY' tag. | |||||||||||||
| 85 | ||||||||||||||
| 86 | A very simple template looks like this: | |||||||||||||
| 87 | ||||||||||||||
| 88 | ||||||||||||||
| 89 | |
|||||||||||||
| 90 | |
|||||||||||||
| 91 | |
|||||||||||||
| 92 | |
|||||||||||||
| 93 | |
|||||||||||||
| 94 | |
|||||||||||||
| 95 | |
|||||||||||||
| 96 | |
|||||||||||||
| 97 | |
|||||||||||||
| 98 | ||||||||||||||
| 99 | ||||||||||||||
| 100 | ||||||||||||||
| 101 | ||||||||||||||
| 102 | |
|||||||||||||
| 103 | ||||||||||||||
| 104 | __MAIN__ | |||||||||||||
| 105 | ||||||||||||||
| 106 | ||||||||||||||
| 107 | ||||||||||||||
| 108 | ||||||||||||||
| 109 | Your input file should then begin something like this: | |||||||||||||
| 110 | ||||||||||||||
| 111 | =HEAD | |||||||||||||
| 112 | title:My Summer Vacation: a novel | |||||||||||||
| 113 | author:John Smith | |||||||||||||
| 114 | myinitials:tla | |||||||||||||
| 115 | myname:Tara L Andrews | |||||||||||||
| 116 | =BODY | |||||||||||||
| 117 | The ^real^ text b\e\gins +(above)t+here. | |||||||||||||
| 118 | ... | |||||||||||||
| 119 | ||||||||||||||
| 120 | ||||||||||||||
| 121 | The real work begins after the '=BODY' tag. The currently-defined sigil | |||||||||||||
| 122 | list is: | |||||||||||||
| 123 | ||||||||||||||
| 124 | %SIGILS = ( | |||||||||||||
| 125 | 'comment' => '##', | |||||||||||||
| 126 | 'add' => '+', | |||||||||||||
| 127 | 'del' => '-', | |||||||||||||
| 128 | 'subst' => "\x{b1}", # Unicode PLUS-MINUS SIGN | |||||||||||||
| 129 | 'div' => "\x{a7}", # Unicode SECTION SIGN | |||||||||||||
| 130 | 'p' => "\x{b6}", # Unicode PILCROW SIGN | |||||||||||||
| 131 | 'ex' => '\\', | |||||||||||||
| 132 | 'expan' => '^', | |||||||||||||
| 133 | 'supplied' => '@', | |||||||||||||
| 134 | 'abbr' => [ '{', '}' ], | |||||||||||||
| 135 | 'num' => '%', | |||||||||||||
| 136 | 'pb' => [ '[', ']' ], | |||||||||||||
| 137 | 'cb' => '|', | |||||||||||||
| 138 | 'hi' => '*', | |||||||||||||
| 139 | 'unclear' => '?', | |||||||||||||
| 140 | 'q' => "\x{2020}", # Unicode DAGGER | |||||||||||||
| 141 | ); | |||||||||||||
| 142 | ||||||||||||||
| 143 | Non-identical matched sets of sigla (e.g. '{}' for abbreviations) should be | |||||||||||||
| 144 | specified in a listref, as seen here. | |||||||||||||
| 145 | ||||||||||||||
| 146 | Whitespace is only significant at the end of lines. If a line which | |||||||||||||
| 147 | contains non-tag text (i.e. words) ends in whitespace, it is assumed that | |||||||||||||
| 148 | the previous word is a complete word. If the line ends with a | |||||||||||||
| 149 | non-whitespace character, it is assume that the word continues onto the | |||||||||||||
| 150 | next line. | |||||||||||||
| 151 | ||||||||||||||
| 152 | All the sigils must be balanced, and they must nest properly. Remember that | |||||||||||||
| 153 | this is a shorthand for XML. I could be convinced to try to autocorrect | |||||||||||||
| 154 | some unbalanced sigils, but it would be worth at least a few pints of cider | |||||||||||||
| 155 | (or, of course, a patch.) | |||||||||||||
| 156 | ||||||||||||||
| 157 | =head2 Tag arguments | |||||||||||||
| 158 | ||||||||||||||
| 159 | Certain of the tags can be passed extra arguments: | |||||||||||||
| 160 | ||||||||||||||
| 161 | =over 4 | |||||||||||||
| 162 | ||||||||||||||
| 163 | =item C |
|||||||||||||
| 164 | ||||||||||||||
| 165 | Anything that appears in parentheses immediately after the add/del opening | |||||||||||||
| 166 | sigil ( + or - in the examples above) will get added as an attribute. If | |||||||||||||
| 167 | the string in parentheses has no '=' sign in it, the attribute for the | |||||||||||||
| 168 | "add" tag will be "place", and the attribute for the "del" tag will be | |||||||||||||
| 169 | "type". Ergo: | |||||||||||||
| 170 | ||||||||||||||
| 171 | +(margin)This is an addition+ | |||||||||||||
| 172 | -(overwrite)and a deletion- to the sentence. | |||||||||||||
| 173 | ||||||||||||||
| 174 | will get translated to | |||||||||||||
| 175 | ||||||||||||||
| 176 | |
|||||||||||||
| 177 | |
|||||||||||||
| 178 | ||||||||||||||
| 179 | This behavior ought to be more configurable and/or flexible; make it worth | |||||||||||||
| 180 | my while. | |||||||||||||
| 181 | ||||||||||||||
| 182 | =item C |
|||||||||||||
| 183 | ||||||||||||||
| 184 | A number value can calculated using a number_conversion function, or it can | |||||||||||||
| 185 | simply be specified. It is also possible to specify the type of number being | |||||||||||||
| 186 | represented (B |
|||||||||||||
| 187 | are separated with a comma, and in the order "value", "type". So for example: | |||||||||||||
| 188 | ||||||||||||||
| 189 | The lead was taken by the Exeter %(8)VIII%. This was their | |||||||||||||
| 190 | %(13,ord)thirteenth% straight win. | |||||||||||||
| 191 | ||||||||||||||
| 192 | will become: | |||||||||||||
| 193 | ||||||||||||||
| 194 | The lead was taken by the Exeter |
|||||||||||||
| 195 | |
|||||||||||||
| 196 | ||||||||||||||
| 197 | =item C |
|||||||||||||
| 198 | ||||||||||||||
| 199 | When text highlighting is encoded, it is almost always a good idea to say | |||||||||||||
| 200 | something about how the highlight was rendered. This information can be passed | |||||||||||||
| 201 | as an argument: | |||||||||||||
| 202 | ||||||||||||||
| 203 | *(red)IN the beginning* was the word | |||||||||||||
| 204 | ||||||||||||||
| 205 | will become | |||||||||||||
| 206 | ||||||||||||||
| 207 | IN the beginning was the word |
|||||||||||||
| 208 | ||||||||||||||
| 209 | =back | |||||||||||||
| 210 | ||||||||||||||
| 211 | =head1 SUBROUTINES | |||||||||||||
| 212 | ||||||||||||||
| 213 | =over 4 | |||||||||||||
| 214 | ||||||||||||||
| 215 | =item B |
|||||||||||||
| 216 | ||||||||||||||
| 217 | Takes the name of a file that holds a marked-up version of text. Returns a | |||||||||||||
| 218 | TEI XML string to represent that text. Options include: | |||||||||||||
| 219 | ||||||||||||||
| 220 | =over 4 | |||||||||||||
| 221 | ||||||||||||||
| 222 | =item C | |||||||||||||
| 223 | ||||||||||||||
| 224 | a string containing the XML template that you want to use for the markup. | |||||||||||||
| 225 | If none is specified, there is a default. That default is useful for me, | |||||||||||||
| 226 | but is very unlikely to be useful for you. =item C |
|||||||||||||
| 227 | ||||||||||||||
| 228 | a mode string to pass to the open() call on the file. Default "<:utf8". | |||||||||||||
| 229 | ||||||||||||||
| 230 | =item C |
|||||||||||||
| 231 | ||||||||||||||
| 232 | a subroutine ref that will calculate the value of number representations. | |||||||||||||
| 233 | Useful for, e.g., Latin numerals. This is optional - if nothing is passed, | |||||||||||||
| 234 | no number value calculation will be attempted. =item C |
|||||||||||||
| 235 | ||||||||||||||
| 236 | a hashref containing the preferred sigil representations of TEI tags. | |||||||||||||
| 237 | Defaults to the list above. | |||||||||||||
| 238 | ||||||||||||||
| 239 | =item C |
|||||||||||||
| 240 | ||||||||||||||
| 241 | Defaults to "true". If you pass a false value, the word wrapping will be | |||||||||||||
| 242 | skipped. | |||||||||||||
| 243 | ||||||||||||||
| 244 | =item C |
|||||||||||||
| 245 | ||||||||||||||
| 246 | Defaults to 0. Controls whether rudimentary formatting is applied to the | |||||||||||||
| 247 | XML returned. Possible values are 0, 1, and "more than 1". See | |||||||||||||
| 248 | XML::LibXML::Document::serialize for more information. (Personally I just | |||||||||||||
| 249 | xmllint it separately.) | |||||||||||||
| 250 | ||||||||||||||
| 251 | =back | |||||||||||||
| 252 | ||||||||||||||
| 253 | The return string is run through the basic formatting mechanism provided by | |||||||||||||
| 254 | XML::LibXML. You may wish to pass it through a pretty printer more to your | |||||||||||||
| 255 | taste. | |||||||||||||
| 256 | ||||||||||||||
| 257 | =cut | |||||||||||||
| 258 | ||||||||||||||
| 259 | # Default list of funky signs I use. | |||||||||||||
| 260 | # TODO: Add header support | |||||||||||||
| 261 | my %SIGILS = ( | |||||||||||||
| 262 | 'comment' => '##', | |||||||||||||
| 263 | 'add' => '+', | |||||||||||||
| 264 | 'del' => '-', | |||||||||||||
| 265 | 'subst' => "\x{b1}", | |||||||||||||
| 266 | 'div' => "\x{a7}", | |||||||||||||
| 267 | 'p' => "\x{b6}", | |||||||||||||
| 268 | 'ex' => '\\', | |||||||||||||
| 269 | 'expan' => '^', | |||||||||||||
| 270 | 'supplied' => '@', | |||||||||||||
| 271 | 'abbr' => [ '{', '}' ], | |||||||||||||
| 272 | 'num' => '%', | |||||||||||||
| 273 | 'pb' => [ '[', ']' ], | |||||||||||||
| 274 | 'cb' => '|', | |||||||||||||
| 275 | 'hi' => '*', | |||||||||||||
| 276 | 'unclear' => '?', | |||||||||||||
| 277 | 'q' => "\x{2020}", | |||||||||||||
| 278 | ); | |||||||||||||
| 279 | ||||||||||||||
| 280 | my @DTL = ; | |||||||||||||
| 281 | my $DEFAULT_TEMPLATE = join( '', @DTL ); | |||||||||||||
| 282 | ||||||||||||||
| 283 | sub to_xml { | |||||||||||||
| 284 | my %opts = ( | |||||||||||||
| 285 | 'number_conversion' => undef, | |||||||||||||
| 286 | 'fileopen_mode' => '<:utf8', | |||||||||||||
| 287 | 'wrap_words' => 1, | |||||||||||||
| 288 | 'sigils' => \%SIGILS, | |||||||||||||
| 289 | 'template' => undef, | |||||||||||||
| 290 | 'format' => 0, | |||||||||||||
| 291 | @_, | |||||||||||||
| 292 | ); | |||||||||||||
| 293 | ||||||||||||||
| 294 | unless( defined( $opts{'file'} ) ) { | |||||||||||||
| 295 | warn "No file specified! Doing nothing."; | |||||||||||||
| 296 | return undef; | |||||||||||||
| 297 | } | |||||||||||||
| 298 | ||||||||||||||
| 299 | if( defined $opts{'number_conversion'} | |||||||||||||
| 300 | && ref( $opts{'number_conversion'} ) ne 'CODE' ) { | |||||||||||||
| 301 | warn "number_conversion argument must be a subroutine ref"; | |||||||||||||
| 302 | $opts{'number_conversion'} = undef; | |||||||||||||
| 303 | } | |||||||||||||
| 304 | ||||||||||||||
| 305 | my $inbody; | |||||||||||||
| 306 | ||||||||||||||
| 307 | my $rc = open( FILE, $opts{'fileopen_mode'}, $opts{'file'} ); | |||||||||||||
| 308 | unless( $rc ) { | |||||||||||||
| 309 | warn "Could not open $opts{'file'}: $@"; | |||||||||||||
| 310 | return undef; | |||||||||||||
| 311 | } | |||||||||||||
| 312 | ||||||||||||||
| 313 | my $tmpl; | |||||||||||||
| 314 | if( defined $opts{'template'} ) { | |||||||||||||
| 315 | $tmpl = $opts{'template'}; | |||||||||||||
| 316 | } else { | |||||||||||||
| 317 | $tmpl = $DEFAULT_TEMPLATE; | |||||||||||||
| 318 | } | |||||||||||||
| 319 | ||||||||||||||
| 320 | my $main_xml; | |||||||||||||
| 321 | ||||||||||||||
| 322 | my( $in_p, $in_div ) = ( undef, undef ); | |||||||||||||
| 323 | while( |
|||||||||||||
| 324 | s/\R+$//g; # chomp, no matter the newline char | |||||||||||||
| 325 | next if /^\s*$/; | |||||||||||||
| 326 | s/^\s*//; # but keep trailing spaces - they're significant! | |||||||||||||
| 327 | _current_context( $_ ); | |||||||||||||
| 328 | ||||||||||||||
| 329 | if( /^=BODY/ ) { | |||||||||||||
| 330 | $inbody = 1; | |||||||||||||
| 331 | # Have we found a responsible person? | |||||||||||||
| 332 | unless( exists $opts{'resp'} ) { | |||||||||||||
| 333 | warn "No responsible person specified for edits!"; | |||||||||||||
| 334 | } | |||||||||||||
| 335 | next; | |||||||||||||
| 336 | } | |||||||||||||
| 337 | ||||||||||||||
| 338 | if( /^(\w+)\s*:\s*(.*)$/ ) { | |||||||||||||
| 339 | # Make the header template substitution. | |||||||||||||
| 340 | _make_warning( "We are in the BODY section but this looks like a header" ) | |||||||||||||
| 341 | if $inbody; | |||||||||||||
| 342 | my( $key, $val ) = ( lc( $1 ), $2 ); | |||||||||||||
| 343 | $val =~ s/\s+$//; | |||||||||||||
| 344 | if( $key eq 'main' ) { | |||||||||||||
| 345 | warn "You cannot use '$key' as a substitution key!"; | |||||||||||||
| 346 | } else { | |||||||||||||
| 347 | $tmpl =~ s/__${key}__/$val/gi; | |||||||||||||
| 348 | } | |||||||||||||
| 349 | if( $key eq 'transcriberid' ) { | |||||||||||||
| 350 | $opts{'resp'} = '#' . $val; | |||||||||||||
| 351 | } | |||||||||||||
| 352 | } | |||||||||||||
| 353 | ||||||||||||||
| 354 | if( $inbody ) { | |||||||||||||
| 355 | # Send it to the parser. | |||||||||||||
| 356 | my $line; | |||||||||||||
| 357 | ## TODO: Upgrade to perl 5.10 to get state variables. | |||||||||||||
| 358 | ( $line, $in_div, $in_p ) = _process_line( $_, $in_div, $in_p, %opts ); | |||||||||||||
| 359 | $main_xml .= $line; | |||||||||||||
| 360 | } | |||||||||||||
| 361 | } | |||||||||||||
| 362 | close FILE; | |||||||||||||
| 363 | ||||||||||||||
| 364 | $tmpl =~ s/__MAIN__/$main_xml/; | |||||||||||||
| 365 | if( $opts{'wrap_words'} ) { | |||||||||||||
| 366 | $tmpl = word_tag_wrap( $tmpl, $opts{'format'} ); | |||||||||||||
| 367 | } else { | |||||||||||||
| 368 | # Just make sure it parses, and format it if asked. | |||||||||||||
| 369 | my $parser = XML::LibXML->new(); | |||||||||||||
| 370 | my $doc; | |||||||||||||
| 371 | my $ok = eval{ $doc = $parser->parse_string( $tmpl ); }; | |||||||||||||
| 372 | unless( $ok ) { | |||||||||||||
| 373 | warn "Parsing of the new XML doc failed: $@"; | |||||||||||||
| 374 | return undef; | |||||||||||||
| 375 | } | |||||||||||||
| 376 | $tmpl = decode( $doc->encoding, $doc->serialize( $opts{'format'} ) ); | |||||||||||||
| 377 | } | |||||||||||||
| 378 | return $tmpl; | |||||||||||||
| 379 | } | |||||||||||||
| 380 | ||||||||||||||
| 381 | sub _process_line { | |||||||||||||
| 382 | my( $line, $in_div, $in_p, %opts ) = @_; | |||||||||||||
| 383 | chomp $line; | |||||||||||||
| 384 | my $checkline = $line; # This should be well-formed by the end | |||||||||||||
| 385 | my $clopts = { %opts, 'nowarn' => 1 }; | |||||||||||||
| 386 | # Look for paragraph and div markers, i.e. our tags that can span multiple lines | |||||||||||||
| 387 | # and that should be disregarded in the checkline. | |||||||||||||
| 388 | my $sigils = $opts{'sigils'}; | |||||||||||||
| 389 | my( $divsig, $pgsig ) = ( $sigils->{'div'}, $sigils->{'p'} ); | |||||||||||||
| 390 | while( $line =~ /\Q$divsig\E(\d*)/g ) { | |||||||||||||
| 391 | my $divno = $1; | |||||||||||||
| 392 | # Calculate the starting position. | |||||||||||||
| 393 | my $pos = pos( $line ) - 1; | |||||||||||||
| 394 | $pos -= length( $divno ) if $divno; | |||||||||||||
| 395 | ||||||||||||||
| 396 | if( $in_div ) { | |||||||||||||
| 397 | _make_warning( "Nonsensical division number at end-division tag; are your '$divsig' tags balanced?" ) | |||||||||||||
| 398 | if $divno; | |||||||||||||
| 399 | substr( $line, $pos, 1, '' ); | |||||||||||||
| 400 | } else { | |||||||||||||
| 401 | my $divstr = ' "; |
|||||||||||||
| 402 | substr( $line, $pos, pos( $line ) - $pos, $divstr ); | |||||||||||||
| 403 | } | |||||||||||||
| 404 | $in_div = !$in_div; | |||||||||||||
| 405 | } | |||||||||||||
| 406 | $checkline =~ s/\Q$divsig\E//g; | |||||||||||||
| 407 | ||||||||||||||
| 408 | while( $line =~ /\Q$pgsig\E/g ) { | |||||||||||||
| 409 | my $p_str = '<' . ( $in_p ? '/' : '' ) . 'p>'; | |||||||||||||
| 410 | substr( $line, pos( $line ) - 1, 1, $p_str ); | |||||||||||||
| 411 | $in_p = !$in_p; | |||||||||||||
| 412 | } | |||||||||||||
| 413 | $checkline =~ s/\Q$pgsig\E//g; | |||||||||||||
| 414 | ||||||||||||||
| 415 | # Add and delete tags. Do this first so that we do not stomp later | |||||||||||||
| 416 | # instances of the dash (e.g. in XML comments). | |||||||||||||
| 417 | my $add_del_re = qr/([-+])(\(([^\)]+)\))?(.*?)\1/; | |||||||||||||
| 418 | while( $line =~ /$add_del_re/g ) { | |||||||||||||
| 419 | my( $op, $attr, $word ) = ( $1, $3, $4 ); | |||||||||||||
| 420 | # Calculate starting position. | |||||||||||||
| 421 | my $pos = pos( $line ) - ( length( $word ) + 2 ); | |||||||||||||
| 422 | # Also for the checkline. | |||||||||||||
| 423 | $checkline =~ /$add_del_re/g; | |||||||||||||
| 424 | my $cpos = pos( $checkline ) - ( length( $word ) + 2 ); | |||||||||||||
| 425 | $pos -= ( length( $attr ) + 2 ) if $attr; | |||||||||||||
| 426 | $cpos -= ( length( $attr ) + 2 ) if $attr; | |||||||||||||
| 427 | # Figure out what the attribute string, if any, should be. | |||||||||||||
| 428 | my $attr_str; | |||||||||||||
| 429 | if( $attr && $attr =~ /\=/ ) { | |||||||||||||
| 430 | $attr_str = $attr; | |||||||||||||
| 431 | } elsif ( $attr ) { | |||||||||||||
| 432 | $attr_str = ( $op eq '+' ? "place" : "type" ) | |||||||||||||
| 433 | . "=\"$attr\""; | |||||||||||||
| 434 | } | |||||||||||||
| 435 | my $interp_str = '<' . ( $op eq '+' ? 'add' : 'del' ) | |||||||||||||
| 436 | . ( $attr_str ? " $attr_str" : '' ) | |||||||||||||
| 437 | . ">$word" . ( $op eq '+' ? 'add' : 'del' ) . '>'; | |||||||||||||
| 438 | substr( $line, $pos, pos( $line ) - $pos, $interp_str ); | |||||||||||||
| 439 | substr( $checkline, $cpos, pos( $checkline ) - $cpos, $interp_str ); | |||||||||||||
| 440 | } | |||||||||||||
| 441 | ||||||||||||||
| 442 | # All the tags that are not very special cases. | |||||||||||||
| 443 | foreach my $tag ( qw( subst abbr hi ex expan num unclear q supplied ) ) { | |||||||||||||
| 444 | my $tag_sig = $sigils->{$tag}; | |||||||||||||
| 445 | my( $tag_open, $tag_close ); | |||||||||||||
| 446 | if( ref( $tag_sig ) eq 'ARRAY' ) { | |||||||||||||
| 447 | ( $tag_open, $tag_close ) = @$tag_sig; | |||||||||||||
| 448 | } else { | |||||||||||||
| 449 | $tag_open = $tag_close = $tag_sig; | |||||||||||||
| 450 | } | |||||||||||||
| 451 | $line =~ s|\Q$tag_open\E(.*?)\Q$tag_close\E|_open_tag( $tag, $1, \%opts ) . "$tag>"|ge; | |||||||||||||
| 452 | $checkline =~ s|\Q$tag_open\E(.*?)\Q$tag_close\E|_open_tag( $tag, $1, $clopts ) . "$tag>"|ge; | |||||||||||||
| 453 | } | |||||||||||||
| 454 | ||||||||||||||
| 455 | # Standalone tags that aren't special cases. Currently only cb. | |||||||||||||
| 456 | foreach my $tag ( qw( cb ) ) { | |||||||||||||
| 457 | my $tag_sig = $sigils->{$tag}; | |||||||||||||
| 458 | $line =~ s|\Q$tag_sig\E|"<$tag/>"|ge; | |||||||||||||
| 459 | $checkline =~ s|\Q$tag_sig\E|"<$tag/>"|ge; | |||||||||||||
| 460 | } | |||||||||||||
| 461 | ||||||||||||||
| 462 | ||||||||||||||
| 463 | # Page breaks. Defined by the delimiters, plus an optional | |||||||||||||
| 464 | # page/folio number & recto/verso indicator, on a line by itself. | |||||||||||||
| 465 | # Of course other languages may use other sigils to indicate recto | |||||||||||||
| 466 | # verso, so do not look for 'r' and 'v' specifically. | |||||||||||||
| 467 | my $pb_sig = $sigils->{'pb'}; | |||||||||||||
| 468 | my ( $pb_open, $pb_close ); | |||||||||||||
| 469 | if( ref( $pb_sig ) eq 'ARRAY' ) { | |||||||||||||
| 470 | ( $pb_open, $pb_close ) = @$pb_sig; | |||||||||||||
| 471 | } else { | |||||||||||||
| 472 | $pb_open = $pb_sig; | |||||||||||||
| 473 | $pb_close = $pb_sig; | |||||||||||||
| 474 | } | |||||||||||||
| 475 | $line =~ s|^\Q$pb_open\E(\d+(.)?)\Q$pb_close\E\s*$| |
|||||||||||||
| 476 | $checkline =~ s|^\Q$pb_open\E(\d+(.)?)\Q$pb_close\E\s*$| |
|||||||||||||
| 477 | ||||||||||||||
| 478 | # XML comments. Convert ## text ## to | |||||||||||||
| 479 | my $com_sig = $sigils->{'comment'}; | |||||||||||||
| 480 | my ( $com_open, $com_close ); | |||||||||||||
| 481 | if( ref( $com_sig ) eq 'ARRAY' ) { | |||||||||||||
| 482 | ( $com_open, $com_close ) = @$com_sig; | |||||||||||||
| 483 | } else { | |||||||||||||
| 484 | $com_open = $com_close = $com_sig; | |||||||||||||
| 485 | } | |||||||||||||
| 486 | $line =~ s|\Q$com_open\E(.*?)\Q$com_close\E||g; | |||||||||||||
| 487 | $checkline =~ s|\Q$com_open\E(.*?)\Q$com_close\E||g; | |||||||||||||
| 488 | ||||||||||||||
| 489 | # At this point our check-line should be well-balance. Send a warning if not. | |||||||||||||
| 490 | my $parser = XML::LibXML->new(); | |||||||||||||
| 491 | my $fragment; | |||||||||||||
| 492 | my $ok = eval{ $fragment = $parser->parse_balanced_chunk( " |
|||||||||||||
| 493 | unless( $ok ) { | |||||||||||||
| 494 | _make_warning( "Sigils are not properly nested." ); | |||||||||||||
| 495 | } | |||||||||||||
| 496 | ||||||||||||||
| 497 | # Finally, every line with text outside an XML tag must have a line | |||||||||||||
| 498 | # break. Any lb tag should be inside a cb, p, or div tag. | |||||||||||||
| 499 | my $testline = $line; | |||||||||||||
| 500 | $testline =~ s/<[^>]*>//g; | |||||||||||||
| 501 | if( $testline =~ /\S/ ) { | |||||||||||||
| 502 | no warnings 'uninitialized'; | |||||||||||||
| 503 | $line =~ s!(|| $1!; |
|||||||||||||
| 504 | } | |||||||||||||
| 505 | ||||||||||||||
| 506 | # Return the expanded line. | |||||||||||||
| 507 | return( "$line\n", $in_div, $in_p ); | |||||||||||||
| 508 | } | |||||||||||||
| 509 | ||||||||||||||
| 510 | sub _open_tag { | |||||||||||||
| 511 | my( $tag, $text, $opts ) = @_; | |||||||||||||
| 512 | ||||||||||||||
| 513 | my $opened_tag; | |||||||||||||
| 514 | # Does the tag take a parenthesized argument? | |||||||||||||
| 515 | my $arg = ''; | |||||||||||||
| 516 | if( $text =~ /^\(([^\)]+)\)(.*)$/ ) { | |||||||||||||
| 517 | ( $arg, $text ) = ( $1, $2 ); | |||||||||||||
| 518 | } | |||||||||||||
| 519 | if( $tag =~ /^(ex|expan|supplied)$/ ) { | |||||||||||||
| 520 | # It takes a resp agent. | |||||||||||||
| 521 | $opened_tag = '<'. $tag .' resp="' . $opts->{'resp'} . "\">$text"; | |||||||||||||
| 522 | } elsif ( $tag eq 'q' ) { | |||||||||||||
| 523 | # Special case - we mean a biblical quote. | |||||||||||||
| 524 | $opened_tag = '' . $text; |
|||||||||||||
| 525 | } elsif ( $tag eq 'num' ) { | |||||||||||||
| 526 | # Derive the number's value if requested. | |||||||||||||
| 527 | my $nv; | |||||||||||||
| 528 | if( $arg ) { | |||||||||||||
| 529 | my $nt; | |||||||||||||
| 530 | my %ntabbr = ( | |||||||||||||
| 531 | 'ord' => 'ordinal', | |||||||||||||
| 532 | 'card' => 'cardinal', | |||||||||||||
| 533 | 'frac' => 'fraction', | |||||||||||||
| 534 | 'perc' => 'percentage' ); | |||||||||||||
| 535 | ( $nv, $nt ) = split( /,/, $arg ); | |||||||||||||
| 536 | $nt = $ntabbr{$nt} || $nt; | |||||||||||||
| 537 | if( $nt ) { | |||||||||||||
| 538 | $opened_tag = sprintf( ' |
|||||||||||||
| 539 | $nv, $nt, $text ); | |||||||||||||
| 540 | } else { | |||||||||||||
| 541 | $opened_tag = sprintf( ' |
|||||||||||||
| 542 | } | |||||||||||||
| 543 | } | |||||||||||||
| 544 | unless( defined $nv ) { | |||||||||||||
| 545 | my $numconvert = $opts->{'number_conversion'}; | |||||||||||||
| 546 | if( defined $numconvert ) { | |||||||||||||
| 547 | # Strip any XML markup from the element contents. | |||||||||||||
| 548 | my $parser = XML::LibXML->new(); | |||||||||||||
| 549 | my $fragment; | |||||||||||||
| 550 | my $ok = eval{ $fragment = $parser->parse_balanced_chunk( $text ); }; | |||||||||||||
| 551 | if( $ok ) { | |||||||||||||
| 552 | $nv = &$numconvert( uc( $fragment->textContent() ) ); | |||||||||||||
| 553 | } else { | |||||||||||||
| 554 | _make_warning( "Unbalanced chunk in number tag: $text" ) | |||||||||||||
| 555 | unless $opts->{nowarn}; | |||||||||||||
| 556 | } | |||||||||||||
| 557 | $opened_tag = sprintf( ' |
|||||||||||||
| 558 | if defined $nv; | |||||||||||||
| 559 | } | |||||||||||||
| 560 | } | |||||||||||||
| 561 | } elsif ( $tag eq 'hi' ) { | |||||||||||||
| 562 | unless( $arg ) { | |||||||||||||
| 563 | _make_warning( "What kind of highlighting is this?" ) | |||||||||||||
| 564 | unless $opts->{nowarn}; | |||||||||||||
| 565 | $arg = 'DEFAULT'; | |||||||||||||
| 566 | } | |||||||||||||
| 567 | $arg =~ s/\s+/_/g; | |||||||||||||
| 568 | $opened_tag = sprintf( '<%s rend="%s">%s', $tag, $arg, $text ); | |||||||||||||
| 569 | } | |||||||||||||
| 570 | ||||||||||||||
| 571 | # The default | |||||||||||||
| 572 | $opened_tag = "<$tag>$text" unless $opened_tag; | |||||||||||||
| 573 | return $opened_tag; | |||||||||||||
| 574 | } | |||||||||||||
| 575 | ||||||||||||||
| 576 | sub _make_warning { | |||||||||||||
| 577 | my $message = shift; | |||||||||||||
| 578 | my $context = _current_context(); | |||||||||||||
| 579 | my $warning = "($.) $context\n\tPossible problem! $message"; | |||||||||||||
| 580 | warn $warning; | |||||||||||||
| 581 | } | |||||||||||||
| 582 | ||||||||||||||
| 583 | ||||||||||||||
| 584 | ## Utility to keep track of where we are | |||||||||||||
| 585 | { | |||||||||||||
| 586 | my $curr_line; | |||||||||||||
| 587 | ||||||||||||||
| 588 | sub _current_context { | |||||||||||||
| 589 | if( @_ ) { | |||||||||||||
| 590 | $curr_line = shift; | |||||||||||||
| 591 | } | |||||||||||||
| 592 | return $curr_line; | |||||||||||||
| 593 | } | |||||||||||||
| 594 | } | |||||||||||||
| 595 | ||||||||||||||
| 596 | =item B |
|||||||||||||
| 597 | ||||||||||||||
| 598 | Takes a string containing a TEI XML document, and returns that | |||||||||||||
| 599 | document with all its words wrapped in |
|||||||||||||
| 600 | "word" is defined as a series of text characters separated by | |||||||||||||
| 601 | whitespace. A word can have a line break, or even a page break, in | |||||||||||||
| 602 | the middle; if this is the case, there I |
|||||||||||||
| 603 | between the end of the first word segment and the (or |
|||||||||||||
| 604 | tag. Conversely, there I (or |
|||||||||||||
| 605 | |
|||||||||||||
| 606 | ||||||||||||||
| 607 | =cut | |||||||||||||
| 608 | ||||||||||||||
| 609 | sub word_tag_wrap { | |||||||||||||
| 610 | my( $xml, $format ) = @_; | |||||||||||||
| 611 | ||||||||||||||
| 612 | my $ret; | |||||||||||||
| 613 | my $doc; | |||||||||||||
| 614 | my $root; | |||||||||||||
| 615 | if( !ref( $xml ) ) { | |||||||||||||
| 616 | $ret = 'string'; | |||||||||||||
| 617 | my $parser = XML::LibXML->new(); | |||||||||||||
| 618 | $doc = $parser->parse_string( $xml ); | |||||||||||||
| 619 | $root = $doc->getDocumentElement(); | |||||||||||||
| 620 | } elsif( ref( $xml ) eq 'XML::LibXML::Document' ) { | |||||||||||||
| 621 | $ret = 'xml'; | |||||||||||||
| 622 | $root = $xml->getDocumentElement(); | |||||||||||||
| 623 | } elsif( ref( $xml ) eq 'XML::LibXML::Element' ) { | |||||||||||||
| 624 | $ret = 'xml'; | |||||||||||||
| 625 | $root = $xml; | |||||||||||||
| 626 | } else { | |||||||||||||
| 627 | die "Passed argument is neither string, Document, or Element"; | |||||||||||||
| 628 | } | |||||||||||||
| 629 | ||||||||||||||
| 630 | my @paragraphs; | |||||||||||||
| 631 | foreach my $t ( $root->getElementsByTagName( 'text' ) ) { | |||||||||||||
| 632 | # Get the paragraphs in this text node; if it is already the same as | |||||||||||||
| 633 | # a paragraph in our list, skip it. | |||||||||||||
| 634 | foreach my $p ( $t->getElementsByTagName( 'p' ) ) { | |||||||||||||
| 635 | next if grep { $_->isSameNode( $p ) } @paragraphs; | |||||||||||||
| 636 | push( @paragraphs, $p ); | |||||||||||||
| 637 | } | |||||||||||||
| 638 | } | |||||||||||||
| 639 | foreach my $p ( @paragraphs ) { | |||||||||||||
| 640 | my $new_p = _wrap_children( $p ); | |||||||||||||
| 641 | # Remove the final whitespace from the paragraphs | |||||||||||||
| 642 | my $lc = $new_p->lastChild; | |||||||||||||
| 643 | if( ref( $lc ) eq 'XML::LibXML::Text' && $lc->data =~ /^\s+$/ ) { | |||||||||||||
| 644 | $new_p->removeChild( $lc ); | |||||||||||||
| 645 | } | |||||||||||||
| 646 | $p->replaceNode( $new_p ); | |||||||||||||
| 647 | } | |||||||||||||
| 648 | ||||||||||||||
| 649 | # Annoyingly, we have to decode the encoding that takes place when | |||||||||||||
| 650 | # the string is returned. | |||||||||||||
| 651 | if( $ret eq 'string' ) { | |||||||||||||
| 652 | $format = 0 unless $format; | |||||||||||||
| 653 | return decode( $doc->encoding(), $doc->serialize( $format ) ); | |||||||||||||
| 654 | } # else the doc has been modified and we need return nothing. | |||||||||||||
| 655 | } | |||||||||||||
| 656 | ||||||||||||||
| 657 | sub _wrap_children { | |||||||||||||
| 658 | my $node = shift; | |||||||||||||
| 659 | my @children = $node->childNodes; | |||||||||||||
| 660 | ||||||||||||||
| 661 | # Make a new version of the element in question, with its name & attributes | |||||||||||||
| 662 | my $new_node = XML::LibXML::Element->new( $node->nodeName ); | |||||||||||||
| 663 | # Set the namespace | |||||||||||||
| 664 | my $docns = $node->namespaceURI; | |||||||||||||
| 665 | $new_node->setNamespace( $docns ); | |||||||||||||
| 666 | foreach my $attr ( $node->attributes ) { | |||||||||||||
| 667 | my( $aname, $aval ) = split( /=/, $attr ); | |||||||||||||
| 668 | $aname =~ s/\s+//g; | |||||||||||||
| 669 | $aval =~ s/\"//g; | |||||||||||||
| 670 | $new_node->setAttribute( $aname, $aval ); | |||||||||||||
| 671 | } | |||||||||||||
| 672 | my $open_word_node = undef; | |||||||||||||
| 673 | foreach my $c ( @children ) { | |||||||||||||
| 674 | # Is it a text node? | |||||||||||||
| 675 | if( ref( $c ) eq 'XML::LibXML::Text' ) { | |||||||||||||
| 676 | # Get the text. | |||||||||||||
| 677 | my $str = $c->textContent; | |||||||||||||
| 678 | # Strip out carriage returns and their surrounding spaces. | |||||||||||||
| 679 | # Carriage returns should only occur after elements, |
|||||||||||||
| 680 | # and the spaces around them should therefore be insignificant. | |||||||||||||
| 681 | $str =~ s/^\s*\n\s*//gs; | |||||||||||||
| 682 | # If there is nothing at all but a newline + initial spaces, | |||||||||||||
| 683 | # pretend that the node isn't there at all. | |||||||||||||
| 684 | next unless $str; | |||||||||||||
| 685 | ||||||||||||||
| 686 | # Get the individual words. | |||||||||||||
| 687 | my @words = split( /\s+/, $str ); | |||||||||||||
| 688 | ||||||||||||||
| 689 | # Finish out the last word if we need to. | |||||||||||||
| 690 | if( $open_word_node ) { | |||||||||||||
| 691 | # If there are any words in this text string, the | |||||||||||||
| 692 | # first one should be used to close out the open node. | |||||||||||||
| 693 | # If the first word is empty, it's a space and the | |||||||||||||
| 694 | # word should just be closed. If there are no words | |||||||||||||
| 695 | # at all, it was just a space. If the first word was | |||||||||||||
| 696 | # all there is, we haven't encountered a space yet and | |||||||||||||
| 697 | # need to keep the word open. | |||||||||||||
| 698 | if( @words ) { | |||||||||||||
| 699 | my $first = shift @words; | |||||||||||||
| 700 | $open_word_node->appendText( $first ) if $first; | |||||||||||||
| 701 | } else { | |||||||||||||
| 702 | $open_word_node = undef unless @words; | |||||||||||||
| 703 | } | |||||||||||||
| 704 | } | |||||||||||||
| 705 | ||||||||||||||
| 706 | foreach( @words ) { | |||||||||||||
| 707 | # Skip whitespace "words" | |||||||||||||
| 708 | next unless /\S/; | |||||||||||||
| 709 | ||||||||||||||
| 710 | # Make a new node for the word | |||||||||||||
| 711 | my $word_node = XML::LibXML::Element->new( 'w' ); | |||||||||||||
| 712 | $word_node->setNamespace( $docns ); | |||||||||||||
| 713 | $word_node->appendText( $_ ); | |||||||||||||
| 714 | $new_node->appendChild( $word_node ); | |||||||||||||
| 715 | $new_node->appendText(' '); | |||||||||||||
| 716 | # ...and keep it open until we find a new word or a space | |||||||||||||
| 717 | $open_word_node = $word_node; | |||||||||||||
| 718 | } | |||||||||||||
| 719 | ||||||||||||||
| 720 | # Close the last word node if our text node ends in a space. | |||||||||||||
| 721 | if( $str =~ /\s+$/s ) { | |||||||||||||
| 722 | $open_word_node = undef; | |||||||||||||
| 723 | } | |||||||||||||
| 724 | } else { | |||||||||||||
| 725 | my $wrapped_child; | |||||||||||||
| 726 | if ( ref( $c ) ne 'XML::LibXML::Comment' && $c->textContent ne '' | |||||||||||||
| 727 | && $c->textContent =~ /\s+/ ) { | |||||||||||||
| 728 | # Recurse on any node that itself contains whitespace-separated text. | |||||||||||||
| 729 | my $new_c = _wrap_children( $c ); | |||||||||||||
| 730 | $wrapped_child = ( $c->toString() ne $new_c->toString() ); | |||||||||||||
| 731 | $c = $new_c; | |||||||||||||
| 732 | } | |||||||||||||
| 733 | ||||||||||||||
| 734 | # If there is an open word node, make it a seg and append | |||||||||||||
| 735 | # our result there; if the child has text content but no | |||||||||||||
| 736 | # word children, wrap it in a new seg; otherwise just pass | |||||||||||||
| 737 | # it on through. | |||||||||||||
| 738 | if( $open_word_node ) { | |||||||||||||
| 739 | $open_word_node->setNodeName( 'seg' ); | |||||||||||||
| 740 | $open_word_node->setAttribute( 'type', 'word' ); | |||||||||||||
| 741 | $open_word_node->appendChild( $c ); | |||||||||||||
| 742 | } elsif( ref( $c ) eq 'XML::LibXML::Comment' || $c->textContent eq '' | |||||||||||||
| 743 | || $wrapped_child ) { | |||||||||||||
| 744 | $new_node->appendChild( $c ); | |||||||||||||
| 745 | } else { | |||||||||||||
| 746 | my $segment_node = XML::LibXML::Element->new( 'seg' ); | |||||||||||||
| 747 | $segment_node->setNamespace( $docns ); | |||||||||||||
| 748 | $segment_node->setAttribute( 'type', 'word' ); | |||||||||||||
| 749 | $segment_node->appendChild( $c ); | |||||||||||||
| 750 | $new_node->appendChild( $segment_node ); | |||||||||||||
| 751 | $new_node->appendText(' '); | |||||||||||||
| 752 | # Keep it open in case there is not a leading space on the next | |||||||||||||
| 753 | # text node. | |||||||||||||
| 754 | $open_word_node = $segment_node; | |||||||||||||
| 755 | } | |||||||||||||
| 756 | } | |||||||||||||
| 757 | } | |||||||||||||
| 758 | ||||||||||||||
| 759 | return $new_node; | |||||||||||||
| 760 | } | |||||||||||||
| 761 | ||||||||||||||
| 762 | 1; | |||||||||||||
| 763 | ||||||||||||||
| 764 | =back | |||||||||||||
| 765 | ||||||||||||||
| 766 | =head1 BUGS / TODO | |||||||||||||
| 767 | ||||||||||||||
| 768 | The XML is not currently validated against a schema. This is mostly | |||||||||||||
| 769 | because I have been unable to get RelaxNG validation to work against | |||||||||||||
| 770 | certain TEI schemas. | |||||||||||||
| 771 | ||||||||||||||
| 772 | This module is currently in a state that I know to be useful to me. | |||||||||||||
| 773 | If it looks like it might be useful to you, but something is bugging | |||||||||||||
| 774 | you about it, report it! | |||||||||||||
| 775 | ||||||||||||||
| 776 | =head1 LICENSE | |||||||||||||
| 777 | ||||||||||||||
| 778 | This package is free software and is provided "as is" without express | |||||||||||||
| 779 | or implied warranty. You can redistribute it and/or modify it under | |||||||||||||
| 780 | the same terms as Perl itself. | |||||||||||||
| 781 | ||||||||||||||
| 782 | =head1 AUTHOR | |||||||||||||
| 783 | ||||||||||||||
| 784 | Tara L Andrews, L |
|||||||||||||
| 785 | ||||||||||||||
| 786 | ||||||||||||||
| 787 | =cut | |||||||||||||
| 788 | ||||||||||||||
| 789 | __DATA__ |