Lucene field indexer written in perl
diff --git a/lib/KorAP/Bundle.pm b/lib/KorAP/Bundle.pm
new file mode 100644
index 0000000..43997e3
--- /dev/null
+++ b/lib/KorAP/Bundle.pm
@@ -0,0 +1,5 @@
+package KorAP::Bundle;
+
+our $VERSION = 0.01;
+
+1;
diff --git a/lib/KorAP/Document.pm b/lib/KorAP/Document.pm
new file mode 100644
index 0000000..8f80e6c
--- /dev/null
+++ b/lib/KorAP/Document.pm
@@ -0,0 +1,250 @@
+package KorAP::Document;
+use Mojo::Base -base;
+use v5.16;
+
+use Mojo::ByteStream 'b';
+use Mojo::DOM;
+use Carp qw/croak carp/;
+use KorAP::Document::Primary;
+
+has [qw/id corpus_id path/];
+has [qw/pub_date title sub_title pub_place/];
+
+# parse document
+sub parse {
+  my $self = shift;
+  my $file = b($self->path . 'data.xml')->slurp;
+
+  state $unable = 'Unable to parse document';
+
+  carp 'Parse document ' . $self->path;
+
+  my $dom = Mojo::DOM->new($file);
+
+  my $rt = $dom->at('raw_text');
+
+  # Get document id and corpus id
+  if ($rt && $rt->attr('docid')) {
+    $self->id($rt->attr('docid'));
+    if ($self->id =~ /^([^_]+)_/) {
+      $self->corpus_id($1);
+    }
+    else {
+      croak $unable;
+    };
+  }
+  else {
+    croak $unable;
+  };
+
+  # Get primary data
+  my $pd = $rt->at('text');
+  if ($pd) {
+
+    $pd = b($pd->text)->decode;
+    $self->{pd} = KorAP::Document::Primary->new($pd->to_string);
+  }
+  else {
+    croak $unable;
+  };
+
+  # Get meta data
+  $self->_parse_meta;
+  return 1;
+};
+
+
+# Primary data
+sub primary {
+  $_[0]->{pd};
+};
+
+sub author {
+  my $self = shift;
+
+  # Set authors
+  if ($_[0]) {
+    return $self->{authors} = [
+      grep { $_ !~ m{^\s*u\.a\.\s*$} } split(/;\s+/, shift())
+    ];
+  }
+  return ($self->{authors} // []);
+};
+
+sub text_class {
+  my $self = shift;
+  if ($_[0]) {
+    return $self->{topics} = [ @_ ];
+  };
+  return ($self->{topics} // []);
+};
+
+
+
+sub _parse_meta {
+  my $self = shift;
+
+  my $file = b($self->path . 'header.xml')->slurp->decode('iso-8859-1')->encode;
+
+  my $dom = Mojo::DOM->new($file);
+  my $monogr = $dom->at('monogr');
+
+  # Get title
+  my $title = $monogr->at('h\.title[type=main]');
+  $self->title($title->text) if $title;
+
+  # Get Subtitle
+  my $sub_title = $monogr->at('h\.title[type=sub]');
+  $self->sub_title($sub_title->text) if $sub_title;
+
+  # Get Author
+  my $author = $monogr->at('h\.author');
+  $self->author($author->all_text) if $author;
+
+  # Get pubDate
+  my $year = $dom->at("pubDate[type=year]");
+  $year = $year ? $year->text : 0;
+  my $month = $dom->at("pubDate[type=month]");
+  $month = $month ? $month->text : 0;
+  my $day = $dom->at("pubDate[type=day]");
+  $day = $day ? $day->text : 0;
+
+  my $date = $year ? ($year < 100 ? '20' . $year : $year) : '0000';
+  $date .= length($month) == 1 ? '0' . $month : $month;
+  $date .= length($day) == 1 ? '0' . $day : $day;
+
+  $self->pub_date($date);
+
+  # Get textClasses
+  my @topic;
+  $dom->find("textClass catRef")->each(
+    sub {
+      my ($ign, @ttopic) = split('\.', $_->attr('target'));
+      push(@topic, @ttopic);
+    }
+  );
+  $self->text_class(@topic);
+};
+
+
+1;
+
+
+__END__
+
+=pod
+
+=head1 NAME
+
+KorAP::Document
+
+
+=head1 SYNOPSIS
+
+  my $doc = KorAP::Document->new(
+    path => 'mydoc-1/'
+  );
+
+  $doc->parse;
+
+  print $doc->title;
+
+
+=head1 DESCRIPTION
+
+Parse the primary and meta data of a document.
+
+
+=head2 ATTRIBUTES
+
+=head2 id
+
+  $doc->id(75476);
+  print $doc->id;
+
+The unique identifier of the document.
+
+
+=head2 corpus_id
+
+  $doc->corpus_id(4);
+  print $doc->corpus_id;
+
+The unique identifier of the corpus.
+
+
+=head2 path
+
+  $doc->path("example-004/");
+  print $doc->path;
+
+The path of the document.
+
+
+=head2 title
+
+  $doc->title("Der Name der Rose");
+  print $doc->title;
+
+The title of the document.
+
+
+=head2 sub_title
+
+  $doc->sub_title("Natürlich eine Handschrift");
+  print $doc->sub_title;
+
+The title of the document.
+
+
+=head2 pub_place
+
+  $doc->pub_place("Rom");
+  print $doc->pub_place;
+
+The publication place of the document.
+
+
+=head2 pub_date
+
+  $doc->pub_place("19800404");
+  print $doc->pub_place;
+
+The publication date of the document,
+in the format "YYYYMMDD".
+
+
+=head2 primary
+
+  print $doc->primary->data(0,20);
+
+The L<KorAP::Document::Primary> object containing the primary data.
+
+
+=head2 author
+
+  $doc->author('Binks, Jar Jar; Luke Skywalker');
+  print $doc->author->[0];
+
+Set the author value as semikolon separated list of names or
+get an array reference of author names.
+
+=head2 text_class
+
+  $doc->text_class(qw/news sports/);
+  print $doc->text_class->[0];
+
+Set the text class as an array or get an array
+reference of text classes.
+
+
+=head1 METHODS
+
+=head2 parse
+
+  $doc->parse;
+
+Run the parsing process of the document
+
+
+=cut
diff --git a/lib/KorAP/Document/Primary.pm b/lib/KorAP/Document/Primary.pm
new file mode 100644
index 0000000..52ca844
--- /dev/null
+++ b/lib/KorAP/Document/Primary.pm
@@ -0,0 +1,153 @@
+package KorAP::Document::Primary;
+use strict;
+use warnings;
+use Carp qw/croak/;
+use Mojo::ByteStream 'b';
+use feature 'state';
+use Packed::Array;
+
+
+# Constructor
+sub new {
+  my $class = shift;
+  bless [shift()], $class;
+};
+
+
+# Get the data as a substring
+sub data {
+  my $self = shift;
+  my ($from, $to) = @_;
+
+  return b(substr($self->[0], $from))->encode if $from && !$to;
+
+  return b($self->[0])->encode unless $to;
+
+  my $substr = substr($self->[0], $from, $to - $from);
+  if ($substr) {
+    return b($substr)->encode;
+  };
+  # encode 'UTF-8',
+  croak 'Unable to find substring';
+};
+
+
+# The length of the primary text in characters
+sub data_length {
+  my $self = shift;
+  return $self->[1] if $self->[1];
+  $self->[1] = length($self->[0]);
+  return $self->[1];
+};
+
+
+# Get correct offset
+sub bytes2chars {
+  my $self = shift;
+  unless ($self->[2]) {
+    $self->_calc_chars;
+  };
+  return $self->[2]->[shift];
+};
+
+
+# Calculate character offsets
+sub _calc_chars {
+  use bytes;
+
+  my $self = shift;
+  tie my @array, 'Packed::Array';
+
+  state $leading = pack( 'B8', '10000000' );
+  state $start   = pack( 'B8', '01000000' );
+
+  my ($i, $j) = (0,0);
+  my $c;
+
+  # Init array
+  my $l = length($self->[0]);
+  $array[$l-1] = 0;
+
+  # Iterate over every character
+  while ($i < $l) {
+
+    # Get actual character
+    $c = substr($self->[0], $i, 1);
+
+    # store character position
+    $array[$i++] = $j;
+
+    # This is the start of a multibyte sequence
+    if (ord($c & $leading) && ord($c & $start)) {
+
+      # Get the next byte - expecting a following character
+      $c = substr($self->[0], $i, 1);
+
+      # Character is part of a multibyte
+      while (ord($c & $leading)) {
+
+	# Set count
+	$array[$i] = (ord($c & $start)) ? ++$j : $j;
+
+	# Get next character
+	$c = substr($self->[0], ++$i, 1);
+      };
+    };
+
+    $j++;
+  };
+
+  $self->[2] = \@array;
+};
+
+
+1;
+
+
+__END__
+
+=pod
+
+=head1 NAME
+
+KorAP::Document::Primary
+
+=head1 SYNOPSIS
+
+  my $text = KorAP::Document::Primary('Das ist mein Text');
+  print $text->data(2,5);
+  print $text->data_length;
+
+
+=head1 DESCRIPTION
+
+Represent textual data with annotated character and byte offsets.
+
+
+=head1 ATTRIBUTES
+
+=head2 data_length
+
+  print $text->data_length;
+
+The textual length in number of characters.
+
+
+=head1 METHODS
+
+=head2 data
+
+  print $text->data;
+  print $text->data(4);
+  print $text->data(5,17);
+
+Return the textual data as a substring. Accepts a starting offset and the length of
+the requested data. The data will be wrapped in an utf-8 encoded L<Mojo::ByteStream>.
+
+=head2 bytes2chars
+
+  print $text->bytes2chars(40);
+
+Calculates the character offset based on a given byte offset.
+
+=cut
diff --git a/lib/KorAP/MultiTerm.pm b/lib/KorAP/MultiTerm.pm
new file mode 100644
index 0000000..9dd12a9
--- /dev/null
+++ b/lib/KorAP/MultiTerm.pm
@@ -0,0 +1,37 @@
+package KorAP::MultiTerm;
+use Mojo::Base -base;
+
+has [qw/p_start p_end o_start o_end term payload/];
+has store_offsets => 1;
+
+sub to_string {
+  my $self = shift;
+  my $string = $self->term;
+  if (defined $self->o_start) {
+    $string .= '#' .$self->o_start .'-' . $self->o_end;
+#  }
+#  elsif (!$self->storeOffsets) {
+#    $string .= '#-';
+  };
+
+
+  my $pl = $self->p_end ? $self->p_end - 1 : $self->payload;
+  if ($self->p_end || $self->payload) {
+    $string .= '$';
+    if ($self->p_end) {
+      $string .= '<i>' . $self->p_end;
+    };
+    if ($self->payload) {
+      if (index($self->payload, '<') == 0) {
+	$string .= $self->payload;
+      }
+      else {
+	$string .= '<?>' . $self->payload;
+      };
+    };
+  };
+
+  return $string;
+};
+
+1;
diff --git a/lib/KorAP/MultiTermToken.pm b/lib/KorAP/MultiTermToken.pm
new file mode 100644
index 0000000..9df9811
--- /dev/null
+++ b/lib/KorAP/MultiTermToken.pm
@@ -0,0 +1,34 @@
+package KorAP::MultiTermToken;
+use KorAP::MultiTerm;
+use Mojo::Base -base;
+
+has [qw/o_start o_end/];
+
+sub add {
+  my $self = shift;
+  my $mt;
+  unless (ref $_[0] eq 'MultiTerm') {
+    if (@_ == 1) {
+      $mt = KorAP::MultiTerm->new(term => shift());
+    }
+    else {
+      $mt = KorAP::MultiTerm->new(@_);
+    };
+  }
+  else {
+    $mt = shift;
+  };
+  $self->{mt} //= [];
+  push(@{$self->{mt}}, $mt);
+  return $mt;
+};
+
+sub to_string {
+  my $self = shift;
+  my $string = '[(' . $self->o_start . '-'. $self->o_end . ')';
+  $string .= join ('|', map($_->to_string, @{$self->{mt}}));
+  $string .= ']';
+  return $string;
+};
+
+1;
diff --git a/lib/KorAP/MultiTermTokenStream.pm b/lib/KorAP/MultiTermTokenStream.pm
new file mode 100644
index 0000000..6d7dc29
--- /dev/null
+++ b/lib/KorAP/MultiTermTokenStream.pm
@@ -0,0 +1,33 @@
+package KorAP::MultiTermTokenStream;
+use Mojo::Base -base;
+use KorAP::MultiTermToken;
+
+has [qw/oStart oEnd/];
+
+sub add {
+  my $self = shift;
+  my $mtt = shift // KorAP::MultiTermToken->new;
+  $self->{mtt} //= [];
+  push(@{$self->{mtt}}, $mtt);
+  return $mtt;
+};
+
+sub add_meta {
+  my $self = shift;
+  my $mt = $self->pos(0)->add('-:' . shift);
+  $mt->payload(shift);
+  $mt->store_offsets(0);
+};
+
+sub pos {
+  my $self = shift;
+  my $pos = shift;
+  return $self->{mtt}->[$pos];
+};
+
+sub to_string {
+  my $self = shift;
+  return join("\n" , map { $_->to_string } @{$self->{mtt}}) . "\n";
+};
+
+1;
diff --git a/lib/KorAP/Tokenizer.pm b/lib/KorAP/Tokenizer.pm
new file mode 100644
index 0000000..7038cc4
--- /dev/null
+++ b/lib/KorAP/Tokenizer.pm
@@ -0,0 +1,362 @@
+package KorAP::Tokenizer;
+
+use Mojo::Base -base;
+use Mojo::ByteStream 'b';
+use Carp qw/carp croak/;
+use KorAP::Tokenizer::Range;
+use KorAP::Tokenizer::Match;
+use KorAP::Tokenizer::Spans;
+use KorAP::Tokenizer::Tokens;
+use KorAP::MultiTermTokenStream;
+use Log::Log4perl;
+
+has [qw/path foundry layer doc stream should have/];
+
+has 'log' => sub {
+  Log::Log4perl->get_logger(__PACKAGE__)
+};
+
+# Parse tokens of the document
+sub parse {
+  my $self = shift;
+
+  # Create new token stream
+  my $mtts = KorAP::MultiTermTokenStream->new;
+  my $file = b($self->path . $self->foundry . '/' . ($self->layer // 'tokens') . '.xml')->slurp;
+  my $tokens = Mojo::DOM->new($file);
+  $tokens->xml(1);
+
+  my $doc = $self->doc;
+
+  my ($should, $have) = (0, 0);
+
+  # Create range and match objects
+  my $range = KorAP::Tokenizer::Range->new;
+  my $match = KorAP::Tokenizer::Match->new;
+
+  my $old = 0;
+
+  $self->log->trace('Tokenize data ' . $self->foundry . ':' . $self->layer);
+
+  # Iterate over all tokens
+  $tokens->find('span')->each(
+    sub {
+      my $span = $_;
+      my $from = $span->attr('from');
+      my $to = $span->attr('to');
+      my $token = $doc->primary->data($from, $to);
+
+      $should++;
+
+      # Ignore non-word tokens
+      return if $token !~ /[\w\d]/;
+
+      my $mtt = $mtts->add;
+
+      # Add gap for later finding matching positions before or after
+      $range->gap($old, $from, $have) unless $old >= $from;
+
+      # Add surface term
+      $mtt->add('s:' . $token);
+
+      # Add case insensitive term
+      $mtt->add('i:' . lc $token);
+
+      # Add offset information
+      $mtt->o_start($from);
+      $mtt->o_end($to);
+
+      # Store offset information for position matching
+      $range->set($from, $to, $have);
+      $match->set($from, $to, $have);
+
+      $old = $to + 1;
+
+      # Add position term
+      $mtt->add('_' . $have . '#' . $mtt->o_start . '-' . $mtt->o_end);
+
+      $have++;
+    });
+
+  # Add token count
+  $mtts->add_meta('t', '<i>' . $have);
+
+  $range->gap($old, $doc->primary->data_length, $have-1) if $doc->primary->data_length >= $old;
+
+  # Add info
+  $self->stream($mtts);
+  $self->{range} = $range;
+  $self->{match} = $match;
+  $self->should($should);
+  $self->have($have);
+
+    $self->log->debug('With a non-word quota of ' . _perc($self->should, $self->should - $self->have) . ' %');
+};
+
+
+# Get span positions through character offsets
+sub range {
+  return shift->{range} // KorAP::Tokenizer::Range->new;
+};
+
+
+# Get token positions through character offsets
+sub match {
+  return shift->{match} // KorAP::Tokenizer::Match->new;
+};
+
+
+# Add information of spans to the tokens
+sub add_spandata {
+  my $self = shift;
+  my %param = @_;
+
+  croak 'No token data available' unless $self->stream;
+
+  $self->log->trace(
+    ($param{skip} ? 'Skip' : 'Add').' span data '.$param{foundry}.':'.$param{layer}
+  );
+
+  return if $param{skip};
+
+  my $cb = delete $param{cb};
+
+  if ($param{encoding} && $param{encoding} eq 'bytes') {
+    $param{primary} = $self->doc->primary;
+  };
+
+  my $spans = KorAP::Tokenizer::Spans->new(
+    path => $self->path,
+    range => $self->range,
+    %param
+  );
+
+  my $spanarray = $spans->parse;
+
+  if ($spans->should == $spans->have) {
+    $self->log->trace('With perfect alignment!');
+  }
+  else {
+    $self->log->debug('With an alignment quota of ' . _perc($spans->should, $spans->have) . ' %');
+  };
+
+
+  if ($cb) {
+    foreach (@$spanarray) {
+      $cb->($self->stream, $_);
+    };
+    return;
+  };
+  return $spans;
+};
+
+
+# Add information to the tokens
+sub add_tokendata {
+  my $self = shift;
+  my %param = @_;
+
+  croak 'No token data available' unless $self->stream;
+
+  $self->log->trace(
+    ($param{skip} ? 'Skip' : 'Add').' token data '.$param{foundry}.':'.$param{layer}
+  );
+  return if $param{skip};
+
+  my $cb = delete $param{cb};
+
+  if ($param{encoding} && $param{encoding} eq 'bytes') {
+    $param{primary} = $self->doc->primary;
+  };
+
+  my $tokens = KorAP::Tokenizer::Tokens->new(
+    path => $self->path,
+    match => $self->match,
+    %param
+  );
+
+  my $tokenarray = $tokens->parse;
+
+  if ($tokens->should == $tokens->have) {
+    $self->log->trace('With perfect alignment!');
+  }
+  else {
+    my $perc = _perc(
+      $tokens->should, $tokens->have, $self->should, $self->should - $self->have
+    );
+    $self->log->debug('With an alignment quota of ' . $perc);
+  };
+
+  if ($cb) {
+    foreach (@$tokenarray) {
+      $cb->($self->stream, $_);
+    };
+    return;
+  };
+  return $tokens;
+};
+
+
+sub _perc {
+  if (@_ == 2) {
+    # '[' . $_[0] . '/' . $_[1] . ']' .
+    return sprintf("%.2f", ($_[1] * 100) / $_[0]);
+  }
+
+  my $a_should = shift;
+  my $a_have   = shift;
+  my $b_should = shift;
+  my $b_have   = shift;
+  my $a_quota = ($a_have * 100) / $a_should;
+  my $b_quota = ($b_have * 100) / $b_should;
+  return sprintf("%.2f", $a_quota) . '%' .
+    ((($a_quota + $b_quota) <= 100) ?
+       ' [' . sprintf("%.2f", $a_quota + $b_quota) . '%]' : '');
+};
+
+
+1;
+
+
+__END__
+
+=pod
+
+=head1 NAME
+
+KorAP::Tokenizer
+
+=head1 SYNOPSIS
+
+  my $tokens = KorAP::Tokenizer->new(
+    path    => '../examples/00003',
+    doc     => KorAP::Document->new( ... ),
+    foundry => 'opennlp',
+    layer   => 'tokens'
+  );
+
+  $tokens->parse;
+
+=head1 DESCRIPTION
+
+Convert token information from the KorAP XML
+format into Lucene Index compatible token streams.
+
+=head1 ATTRIBUTES
+
+=head2 path
+
+  print $tokens->path;
+
+The path of the document.
+
+
+=head2 foundry
+
+  print $tokens->foundry;
+
+The name of the foundry.
+
+
+=head2 layer
+
+  print $tokens->layer;
+
+The name of the tokens layer.
+
+
+=head2 doc
+
+  print $tokens->doc->corpus_id;
+
+The L<KorAP::Document> object.
+
+
+=head2 stream
+
+  $tokens->stream->add_meta('adjCount', '<i>45');
+
+The L<KorAP::MultiTermTokenStream> object
+
+
+=head2 range
+
+  $tokens->range->lookup(45);
+
+The L<KorAP::Tokenizer::Range> object for converting span offsets to positions.
+
+=head2 match
+
+  $tokens->match->lookup(45);
+
+The L<KorAP::Tokenizer::Match> object for converting token offsets to positions.
+
+
+=head1 METHODS
+
+=head2 parse
+
+  $tokens->parse;
+
+Start the tokenization process.
+
+
+=head2 add_spandata
+
+  $tokens->add_spandata(
+    foundry => 'base',
+    layer => 'sentences',
+    cb => sub {
+      my ($stream, $span) = @_;
+      my $mtt = $stream->pos($span->p_start);
+      $mtt->add(
+	term    => '<>:s',
+	o_start => $span->o_start,
+	o_end   => $span->o_end,
+	p_end   => $span->p_end
+      );
+    }
+  );
+
+Add span information to the parsed token stream.
+Expects a C<foundry> name, a C<layer> name and a
+callback parameter, that will be called after each parsed
+span. The L<KorAP::MultiTermTokenStream> object will be passed,
+as well as the current L<KorAP::Tokenizer::Span>.
+
+An optional parameter C<encoding> may indicate that the span offsets
+are either refering to C<bytes> or C<utf-8> offsets.
+
+An optional parameter C<skip> allows for skipping the process.
+
+
+=head2 add_tokendata
+
+  $tokens->add_tokendata(
+    foundry => 'connexor',
+    layer => 'syntax',
+    cb => sub {
+      my ($stream, $token) = @_;
+      my $mtt = $stream->pos($token->pos);
+      my $content = $token->content;
+
+      # syntax
+      if ((my $found = $content->at('f[name="pos"]')) && ($found = $found->text)) {
+	$mtt->add(
+	  term => 'cnx_syn:' . $found
+	);
+      };
+    });
+
+Add token information to the parsed token stream.
+Expects a C<foundry> name, a C<layer> name and a
+callback parameter, that will be called after each parsed
+token. The L<KorAP::MultiTermTokenStream> object will be passed,
+as well as the current L<KorAP::Tokenizer::Span>.
+
+An optional parameter C<encoding> may indicate that the token offsets
+are either refering to C<bytes> or C<utf-8> offsets.
+
+An optional parameter C<skip> allows for skipping the process.
+
+=cut
diff --git a/lib/KorAP/Tokenizer/Match.pm b/lib/KorAP/Tokenizer/Match.pm
new file mode 100644
index 0000000..e5a96f8
--- /dev/null
+++ b/lib/KorAP/Tokenizer/Match.pm
@@ -0,0 +1,17 @@
+package KorAP::Tokenizer::Match;
+use strict;
+use warnings;
+
+sub new {
+  bless {}, shift;
+};
+
+sub set {
+  $_[0]->{$_[1] . ':' . $_[2]} = $_[3];
+};
+
+sub lookup {
+  $_[0]->{$_[1] . ':' . $_[2]} // undef;
+};
+
+1;
diff --git a/lib/KorAP/Tokenizer/Range.pm b/lib/KorAP/Tokenizer/Range.pm
new file mode 100644
index 0000000..c18136b
--- /dev/null
+++ b/lib/KorAP/Tokenizer/Range.pm
@@ -0,0 +1,55 @@
+package KorAP::Tokenizer::Range;
+use strict;
+use warnings;
+use Array::IntSpan;
+
+sub new {
+  my $class = shift;
+  my $range = Array::IntSpan->new;
+  bless \$range, $class;
+};
+
+sub set {
+  my $self = shift;
+  $$self->set_range(@_);
+};
+
+sub gap {
+  my $self = shift;
+  $$self->set_range($_[0], $_[1], '!' . ($_[2] - 1) . ':' . $_[2]);
+};
+
+sub lookup {
+  my $x = ${$_[0]}->lookup( $_[1] ) or return;
+  return if index($x, '!') == 0;
+  return $x;
+};
+
+sub before {
+  my $self = shift;
+  my $offset = shift;
+  my $found = $$self->lookup( $offset );
+  unless (defined $found) {
+    warn 'There is no value for ', $offset;
+  };
+
+  if ($found =~ /!(\d+):(\d+)$/) {
+    return $1 >= 0 ? $1 : 0;
+  }
+  else {
+    return $found;
+  };
+};
+
+sub after {
+  my $self = shift;
+  my $found = $$self->lookup( shift() );
+  if ($found =~ /!(\d+):(\d+)$/) {
+    return $2;
+  }
+  else {
+    return $found;
+  };
+};
+
+1;
diff --git a/lib/KorAP/Tokenizer/Span.pm b/lib/KorAP/Tokenizer/Span.pm
new file mode 100644
index 0000000..0010d7c
--- /dev/null
+++ b/lib/KorAP/Tokenizer/Span.pm
@@ -0,0 +1,70 @@
+package KorAP::Tokenizer::Span;
+use strict;
+use warnings;
+use Mojo::DOM;
+
+sub new {
+  bless [], shift;
+};
+
+sub o_start {
+  if (defined $_[1]) {
+    $_[0]->[0] = $_[1];
+  };
+  $_[0]->[0];
+};
+
+sub o_end {
+  if (defined $_[1]) {
+    $_[0]->[1] = $_[1];
+  };
+  $_[0]->[1];
+};
+
+sub p_start {
+  if (defined $_[1]) {
+    $_[0]->[2] = $_[1];
+  };
+  $_[0]->[2];
+};
+
+sub p_end {
+  if (defined $_[1]) {
+    $_[0]->[3] = $_[1];
+  };
+  $_[0]->[3];
+};
+
+sub id {
+  if (defined $_[1]) {
+    $_[0]->[4] = $_[1];
+  };
+  $_[0]->[4];
+};
+
+
+sub content {
+  if (defined $_[1]) {
+    $_[0]->[5] = $_[1];
+  }
+  else {
+    if ($_[0]->processed) {
+      return $_[0]->[5];
+    }
+    else {
+      my $c = Mojo::DOM->new($_[0]->[5]);
+      $c->xml(1);
+      $_[0]->processed(1);
+      return $_[0]->[5] = $c;
+    };
+  };
+};
+
+sub processed {
+  if (defined $_[1]) {
+    $_[0]->[6] = $_[1] ? 1 : 0;
+  };
+  $_[0]->[6];
+};
+
+1;
diff --git a/lib/KorAP/Tokenizer/Spans.pm b/lib/KorAP/Tokenizer/Spans.pm
new file mode 100644
index 0000000..7e1a382
--- /dev/null
+++ b/lib/KorAP/Tokenizer/Spans.pm
@@ -0,0 +1,63 @@
+package KorAP::Tokenizer::Spans;
+use Mojo::Base -base;
+use KorAP::Tokenizer::Span;
+use Mojo::DOM;
+use Mojo::ByteStream 'b';
+
+has [qw/path foundry layer range primary should have/];
+has 'encoding' => 'utf-8';
+
+sub parse {
+  my $self = shift;
+  my $file = b($self->path . $self->foundry . '/' . $self->layer . '.xml')->slurp;
+
+  my $spans = Mojo::DOM->new($file);
+  $spans->xml(1);
+
+  my ($should, $have) = (0,0);
+  my ($from, $to);
+
+  my @spans;
+  $spans->find('span')->each(
+    sub {
+      my $s = shift;
+
+      $should++;
+
+      if ($self->encoding eq 'bytes') {
+	$from = $self->primary->bytes2chars($s->attr('from'));
+	$to = $self->primary->bytes2chars($s->attr('to'));
+      }
+      else {
+	$from = $s->attr('from');
+	$to = $s->attr('to');
+      };
+
+      return unless $to > $from;
+
+      my $span = KorAP::Tokenizer::Span->new;
+
+      $span->id($s->attr('id'));
+      $span->o_start($from);
+      $span->o_end($to);
+      $span->p_start($self->range->after($span->o_start));
+      $span->p_end($self->range->before($span->o_end));
+
+      return unless $span->p_end >= $span->p_start;
+
+      if (@{$s->children}) {
+	$span->content($s->content_xml);
+      };
+
+      $have++;
+
+      push(@spans, $span);
+    });
+
+  $self->should($should);
+  $self->have($have);
+
+  return \@spans;
+};
+
+1;
diff --git a/lib/KorAP/Tokenizer/Token.pm b/lib/KorAP/Tokenizer/Token.pm
new file mode 100644
index 0000000..f6c1971
--- /dev/null
+++ b/lib/KorAP/Tokenizer/Token.pm
@@ -0,0 +1,37 @@
+package KorAP::Tokenizer::Token;
+use strict;
+use warnings;
+use Mojo::DOM;
+
+sub new {
+  bless [], shift;
+};
+
+sub pos {
+  if (defined $_[1]) {
+    $_[0]->[0] = $_[1];
+  };
+  $_[0]->[0];
+};
+
+sub content {
+  if ($_[1]) {
+    $_[0]->[1] = $_[1];
+  }
+  else {
+    my $c = Mojo::DOM->new($_[0]->[1]);
+    $c->xml(1);
+    return $c;
+  };
+};
+
+sub id {
+  if ($_[1]) {
+    $_[0]->[2] = $_[1];
+  }
+  else {
+    $_[0]->[2];
+  };
+};
+
+1;
diff --git a/lib/KorAP/Tokenizer/Tokens.pm b/lib/KorAP/Tokenizer/Tokens.pm
new file mode 100644
index 0000000..7657460
--- /dev/null
+++ b/lib/KorAP/Tokenizer/Tokens.pm
@@ -0,0 +1,62 @@
+package KorAP::Tokenizer::Tokens;
+use Mojo::Base -base;
+use Mojo::DOM;
+use Mojo::ByteStream 'b';
+use KorAP::Tokenizer::Token;
+
+has [qw/path foundry layer match primary should have/];
+has 'encoding' => 'utf-8';
+
+sub parse {
+  my $self = shift;
+  my $file = b($self->path . $self->foundry . '/' . $self->layer . '.xml')->slurp;
+
+  my $spans = Mojo::DOM->new($file);
+  $spans->xml(1);
+
+  my ($should, $have) = (0,0);
+  my ($from, $to);
+
+  my $match = $self->match;
+
+  my @tokens;
+  $spans->find('span')->each(
+    sub {
+      my $s = shift;
+
+      $should++;
+
+      if ($self->encoding eq 'bytes') {
+	$from = $self->primary->bytes2chars($s->attr('from'));
+	$to = $self->primary->bytes2chars($s->attr('to'));
+      }
+      else {
+	$from = $s->attr('from');
+	$to = $s->attr('to');
+      };
+
+      my $pos = $match->lookup($from, $to);
+
+      return unless defined $pos;
+
+      my $token = KorAP::Tokenizer::Token->new;
+      $token->id($s->attr('id'));
+      $token->pos($pos);
+
+      if (@{$s->children}) {
+	$token->content($s->content_xml);
+      };
+
+      $have++;
+
+      push(@tokens, $token);
+    });
+
+  $self->should($should);
+  $self->have($have);
+
+  return \@tokens;
+};
+
+
+1;