blob: b1ebfb5be85ee649ef45a2289f8c15c80cb954ec [file] [log] [blame]
Marc Kupietz79ba1e52021-02-12 17:26:54 +01001#!/usr/bin/env perl
2use strict;
3use warnings;
4use POSIX;
Marc Kupietzaeb84a02021-10-11 17:57:29 +02005use Getopt::Long qw(GetOptions :config no_auto_abbrev);
6use Log::Any '$log';
7use Log::Any::Adapter;
Marc Kupietz79ba1e52021-02-12 17:26:54 +01008use Encode;
9use IO::Compress::Zip qw(zip $ZipError :constants);
10use File::Basename;
Marc Kupietzaeb84a02021-10-11 17:57:29 +020011use Pod::Usage;
Marc Kupietz79ba1e52021-02-12 17:26:54 +010012
13my $_COMPRESSION_METHOD = ZIP_CM_DEFLATE;
14my %opts;
15my %processedFilenames;
16
Akronf3efc9e2026-06-03 10:46:37 +020017our $VERSION = '0.7.0';
Marc Kupietzaeb84a02021-10-11 17:57:29 +020018our $VERSION_MSG = "\nconllu2korapxml - v$VERSION\n";
Marc Kupietz4cc243a2021-10-11 17:15:16 +020019
Marc Kupietzaeb84a02021-10-11 17:57:29 +020020use constant {
21 # Set to 1 for minimal more debug output (no need to be parametrized)
22 DEBUG => $ENV{KORAPXMLCONLLU_DEBUG} // 0
23};
Marc Kupietz79ba1e52021-02-12 17:26:54 +010024
Marc Kupietzaeb84a02021-10-11 17:57:29 +020025GetOptions(
26 'force-foundry|f=s' => \(my $foundry_name = ''),
Akron8dabdc12026-06-02 16:36:07 +020027 'text-sigle|s=s' => \(my $text_sigle_template = ''),
Marc Kupietzaeb84a02021-10-11 17:57:29 +020028 'log|l=s' => \(my $log_level = 'warn'),
Marc Kupietz187abd72024-06-25 14:30:01 +020029 'output|o=s' => \(my $outh = '-'),
Marc Kupietz79ba1e52021-02-12 17:26:54 +010030
Marc Kupietzaeb84a02021-10-11 17:57:29 +020031 'help|h' => sub {
32 pod2usage(
33 -verbose => 99,
34 -sections => 'NAME|DESCRIPTION|SYNOPSIS|ARGUMENTS|OPTIONS|EXAMPLES',
35 -msg => $VERSION_MSG,
36 -output => '-'
37 )
38 },
39 'version|v' => sub {
40 pod2usage(
41 -verbose => 0,
42 -msg => $VERSION_MSG,
43 -output => '-'
44 );
45 }
46);
Marc Kupietz79ba1e52021-02-12 17:26:54 +010047
Akron8dabdc12026-06-02 16:36:07 +020048# Validate text-sigle template format
49if ($text_sigle_template) {
50 my $test_sigle = $text_sigle_template;
51 $test_sigle =~ s/\{ID\}/PLACEHOLDER/gi;
52 my @tpl_parts = split('/', $test_sigle);
53 if (scalar @tpl_parts != 3) {
54 die "ERROR: text-sigle template must produce exactly 3"
55 . " slash-separated parts (CORPUS/DOC/TEXT), got "
56 . scalar(@tpl_parts) . " from '$text_sigle_template'\n";
57 }
58 if (grep { $_ eq '' } @tpl_parts) {
59 die "ERROR: text-sigle template '$text_sigle_template'"
60 . " must not contain empty parts\n";
61 }
62}
63
Marc Kupietzaeb84a02021-10-11 17:57:29 +020064# Establish logger
65binmode(STDERR, ':encoding(UTF-8)');
66Log::Any::Adapter->set('Stderr', log_level => $log_level);
67$log->notice('Debugging is activated') if DEBUG;
Marc Kupietz79ba1e52021-02-12 17:26:54 +010068
69my $docid="";
70my $zip = undef;
Marc Kupietz79ba1e52021-02-12 17:26:54 +010071my $parser_file;
72my $parse;
73my $morpho_file;
74my $morpho;
75my @spansFrom;
76my @spansTo;
77my $current;
78my ($unknown, $known) = (0, 0);
Akronf3efc9e2026-06-03 10:46:37 +020079my $sentence_text = '';
80my $doc_offset = 0;
81my $text_pos = 0;
82my $compute_offsets_mode = 0;
83# Buffer dependency arcs until all token offsets in a sentence are known
84my @dep_buffer;
Marc Kupietz79ba1e52021-02-12 17:26:54 +010085
86my ($write_morpho, $write_syntax, $base) = (1, 0, 0);
87my $filename;
Marc Kupietz79ba1e52021-02-12 17:26:54 +010088my $first=1;
89my @conllu_files = @ARGV;
90push @conllu_files, "-" if (@conllu_files == 0);
91my $fh;
Marc Kupietzdd546a82024-03-22 16:30:09 +010092
93my $dependency_foundry_name = $foundry_name;
94if ($foundry_name =~ /(.*) dependency:(.*)/) {
95 $foundry_name = $1;
96 $dependency_foundry_name = $2;
97}
98
Marc Kupietz79ba1e52021-02-12 17:26:54 +010099foreach my $conllu_file (@conllu_files) {
100 if ($conllu_file eq '-') {
101 $fh = \*STDIN;
102 } else {
103 open($fh, "<", $conllu_file) or die "Cannot open $conllu_file";
104 }
105 my $i=0; my $s=0; my $first_in_sentence=0;
106 my $lastDocSigle="";
Akron49f333b2022-09-27 17:03:49 +0200107 MAIN: while (<$fh>) {
Marc Kupietzbcb55b82022-09-15 11:42:26 +0200108 if(/^\s*(?:#|0\.\d)/) {
109 if(/^(?:#|0\.1)\s+filename\s*[:=]\s*(.*)/) {
110 $filename=$1;
111 if(!$first) {
112 closeDoc(0);
113 } else {
114 $first=0;
115 }
116 if($processedFilenames{$filename}) {
117 $log->warn("WARNING: $filename is already processed");
118 }
119 $processedFilenames{$filename}=1;
120 $i=0;
Akronf3efc9e2026-06-03 10:46:37 +0200121 $doc_offset = 0;
122 $sentence_text = '';
123 $compute_offsets_mode = 0;
Akron8dabdc12026-06-02 16:36:07 +0200124 } elsif (/^#\s*newdoc\s+id\s*=\s*(.*)/ && $text_sigle_template) {
125 my $newdoc_id = $1;
126 $newdoc_id =~ s/\s+$//;
127
128 if (!$first) {
129 closeDoc(0);
130 } else {
131 $first = 0;
132 }
133
134 my $text_sigle = $text_sigle_template;
135 $text_sigle =~ s/\{ID\}/$newdoc_id/gi;
136
137 my @parts = split('/', $text_sigle);
138 if (scalar @parts != 3 || grep { $_ eq '' } @parts) {
139 die "ERROR: Expanded text sigle '$text_sigle' is not"
140 . " in valid CORPUS/DOC/TEXT format\n";
141 }
142
143 $filename = "$text_sigle/base/tokens.xml";
144 if ($processedFilenames{$filename}) {
145 $log->warn("WARNING: $filename is already processed");
146 }
147 $processedFilenames{$filename} = 1;
148 $i = 0;
149
150 my $last = pop @parts;
151 $docid = join('_', @parts) . '.' . $last;
152
153 my $docSigle = $docid;
154 $docSigle =~ s/\..*//;
155 if ($docSigle ne $lastDocSigle) {
156 $log->info("Analyzing $docSigle");
157 $lastDocSigle = $docSigle;
158 }
159 $known = $unknown = 0;
160 $current = "";
161
162 $morpho_file = "$text_sigle/$foundry_name/morpho.xml";
163 $parser_file = "$text_sigle/$dependency_foundry_name/dependency.xml";
164
165 $parse = $morpho = layer_header($docid);
Marc Kupietzbcb55b82022-09-15 11:42:26 +0200166 } elsif(/^#\s*foundry\s*[:=]\s*(.*)/) {
167 if(!$foundry_name) {
Marc Kupietzdd546a82024-03-22 16:30:09 +0100168 $dependency_foundry_name = $foundry_name = $1;
169 if ($foundry_name =~ /(.*) dependency:(.*)/) {
170 $foundry_name = $1;
171 $dependency_foundry_name = $2;
172 }
Marc Kupietzbcb55b82022-09-15 11:42:26 +0200173 $log->debug("Foundry: $foundry_name\n");
174 } else {
175 $log->debug("Ignored foundry name: $1\n");
176 }
177 } elsif(/^#\s*generator\s*[=]\s*udpipe/i) {
178 if(!$foundry_name) {
Marc Kupietzdd546a82024-03-22 16:30:09 +0100179 $dependency_foundry_name = $foundry_name = "ud";
Marc Kupietzbcb55b82022-09-15 11:42:26 +0200180 $log->debug("Foundry: $foundry_name\n");
181 } else {
182 $log->debug("Ignored foundry name: ud\n");
183 }
Akron49f333b2022-09-27 17:03:49 +0200184 } elsif(/^(?:#|0\.2)\s+text_id\s*[:=]\s*(.*)/) {
Marc Kupietzbcb55b82022-09-15 11:42:26 +0200185 $docid=$1;
Marc Kupietzcc391472024-06-24 10:48:34 +0200186 $docid =~ s/\s+$//;
Marc Kupietzbcb55b82022-09-15 11:42:26 +0200187 my $docSigle = $docid;
188 $docSigle =~ s/\..*//;
189 if($docSigle ne $lastDocSigle) {
190 $log->info("Analyzing $docSigle");
191 $lastDocSigle = $docSigle;
192 }
193 $known=$unknown=0;
194 $current="";
195 $parser_file = dirname($filename);
196 $parser_file =~ s@(.*)/[^/]+$@$1@;
197 $morpho_file = $parser_file;
198 $morpho_file .= "/$foundry_name/morpho.xml";
Marc Kupietzdd546a82024-03-22 16:30:09 +0100199 $parser_file .= "/$dependency_foundry_name/dependency.xml";
Marc Kupietzbcb55b82022-09-15 11:42:26 +0200200 $parse = $morpho = layer_header($docid);
201 } elsif (/^(?:#|0\.3)\s+(?:start_offsets|from)\s*[:=]\s*(.*)/) {
202 @spansFrom = split(/\s+/, $1);
203 } elsif (/^(?:#|0\.4)\s+(?:end_offsets|to)\s+[:=]\s*(.*)/) {
204 @spansTo = split(/\s+/, $1);
Akronf3efc9e2026-06-03 10:46:37 +0200205 } elsif (/^#\s*text\s*=\s*(.*)/) {
206 # Store sentence text for automatic offset computation
207 $sentence_text = decode('UTF-8', $1);
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100208 }
Akron49f333b2022-09-27 17:03:49 +0200209 } elsif ( !/^\s*$/ ) {
Akronf3efc9e2026-06-03 10:46:37 +0200210 # Pre-split columns before offset computation needs the raw form
211 my @raw_cols = split('\t');
212 chomp $raw_cols[$#raw_cols] if @raw_cols;
213
214 # Enter offset computation mode when no explicit offsets given
215 if (!$compute_offsets_mode && scalar @spansFrom == 0
216 && $sentence_text && $docid) {
217 $compute_offsets_mode = 1;
218 @spansFrom = ();
219 @spansTo = ();
220 # Sentence-level span covers the full sentence text
221 $spansFrom[0] = $doc_offset;
222 $spansTo[0] = $doc_offset + length($sentence_text);
223 $text_pos = 0;
224 }
225
226 # Locate each token form in sentence text via index()
227 if ($compute_offsets_mode && @raw_cols >= 2 && $raw_cols[0] =~ /^\d+$/) {
228 my $raw_form = decode('UTF-8', $raw_cols[1]);
229 my $t_num = $raw_cols[0];
230 # Find token starting from current position (handles SpaceAfter)
231 my $pos_in_text = index($sentence_text, $raw_form, $text_pos);
232 if ($pos_in_text >= 0) {
233 $spansFrom[$t_num] = $doc_offset + $pos_in_text;
234 $spansTo[$t_num] = $spansFrom[$t_num] + length($raw_form);
235 # Advance past this token for the next search
236 $text_pos = $pos_in_text + length($raw_form);
237 } else {
238 $log->warn("WARNING: Token form not found in sentence text in $conllu_file line $.");
239 }
240 }
241
Akron49f333b2022-09-27 17:03:49 +0200242 if ( !$docid || scalar @spansTo == 0 || scalar @spansFrom == 0 ) {
243 if ( !$docid ) {
Marc Kupietz67d8c432024-06-25 14:32:16 +0200244 $log->warn("WARNING: Invalid input in $conllu_file: text_id (e.g. '# text_id = GOE_AGA.00000') missing in line $. when writing to $outh");
Akron49f333b2022-09-27 17:03:49 +0200245 }
246 if ( scalar @spansTo == 0 || scalar @spansFrom == 0 ) {
Marc Kupietz67d8c432024-06-25 14:32:16 +0200247 $log->warn("WARNING: Invalid input in $conllu_file: token offsets missing in line $. when writing to $outh");
Akron49f333b2022-09-27 17:03:49 +0200248 }
249
250 # Skip to next potentially valid document
251 while (<$fh>) {
252 next MAIN if m!^\s*$!s;
253 }
254 };
Marc Kupietzd50de7c2024-03-10 15:24:55 +0100255 my @parsed = map {
256 my $s = $_;
257 $s =~ s/&/&amp;/g;
258 $s =~ s/</&lt;/g;
259 $s =~ s/>/&gt;/g;
260 $s;
Akronf3efc9e2026-06-03 10:46:37 +0200261 } @raw_cols;
Akron49f333b2022-09-27 17:03:49 +0200262 if (@parsed != 10) {
Marc Kupietzaeb84a02021-10-11 17:57:29 +0200263 $log->warn("WARNING: skipping strange parser output line in $docid");
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100264 $i++;
265 next;
266 }
267 my $t=$parsed[0];
268 if($t == 1) {
269 $s++;
270 $first_in_sentence = $i;
271 }
272 if($parsed[6] =~ /\d+/ && $parsed[7] !~ /_/) {
273 $write_syntax=1;
Akronf3efc9e2026-06-03 10:46:37 +0200274 # Buffer dep arcs; flushed at end of sentence when offsets are ready
275 push @dep_buffer, [$s, $t, $parsed[6], $parsed[7]];
Marc Kupietza591cdd2021-10-12 13:23:48 +0200276 }
Marc Kupietz5cc4df22024-03-24 13:46:42 +0100277 my $pos = $parsed[4];
278 my $upos = $parsed[3];
Marc Kupietza591cdd2021-10-12 13:23:48 +0200279 $pos =~ s/\|.*//;
280 $morpho .= qq( <span id="s${s}_n$t" from="$spansFrom[$t]" to="$spansTo[$t]">
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100281 <fs type="lex" xmlns="http://www.tei-c.org/ns/1.0">
282 <f name="lex">
283 <fs>
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100284);
Marc Kupietz5cc4df22024-03-24 13:46:42 +0100285 if($pos ne "_") {
286 $morpho .= qq( <f name="pos">$pos</f>\n);
287 }
288 if($upos ne "_") {
289 $morpho .= qq( <f name="upos">$upos</f>\n);
290 }
Marc Kupietz97ba2ba2021-10-11 17:55:47 +0200291 $morpho .= qq( <f name="lemma">$parsed[2]</f>\n) if($parsed[2] ne "_" || $parsed[1] eq '_');
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100292 $morpho .= qq( <f name="msd">$parsed[5]</f>\n) if($parsed[5] ne "_");
293 if($parsed[9] ne "_") {
294 if ($parsed[9] =~ /[0-9.e]+/) {
295 $morpho .= qq( <f name="certainty">$parsed[9]</f>\n)
296 }
297 else {
298 $morpho .= qq( <f name="misc">$parsed[9]</f>\n)
299 }
300 }
301 $morpho .= qq( </fs>
302 </f>
303 </fs>
304 </span>
305);
306 $i++;
Akronf3efc9e2026-06-03 10:46:37 +0200307 } else {
308 # Empty line = end of sentence
309 flush_dep_buffer();
310 if ($compute_offsets_mode) {
311 # Advance doc offset past sentence + 1-char space separator
312 $doc_offset += length($sentence_text) + 1;
313 @spansFrom = ();
314 @spansTo = ();
315 $compute_offsets_mode = 0;
316 }
317 $sentence_text = '';
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100318 }
319 }
320 $current .= "\n";
321 closeDoc(1);
Akron49f333b2022-09-27 17:03:49 +0200322 $zip->close() if $zip;
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100323 close($fh);
324}
325exit;
326
327sub newZipStream {
328 my ($fname) = @_;
329 if (defined $zip) {
330 $zip->newStream(Zip64 => 1, TextFlag => 1, Method => $_COMPRESSION_METHOD,
Marc Kupietz447f4752024-03-22 17:35:57 +0100331 Append => 1, Name => $fname, ExtAttr => 0100666 << 16)
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100332 or die "ERROR ('$fname'): zip failed: $ZipError\n";
333 } else {
334 $zip = new IO::Compress::Zip $outh, Zip64 => 1, TextFlag => 1,
Marc Kupietz447f4752024-03-22 17:35:57 +0100335 Method => $_COMPRESSION_METHOD, Append => 0, Name => "$fname", ExtAttr => 0100666 << 16
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100336 or die "ERROR ('$fname'): zip failed: $ZipError\n";
337 }
338}
339
Akronf3efc9e2026-06-03 10:46:37 +0200340# Write buffered dependency arcs now that all token offsets are resolved
341sub flush_dep_buffer {
342 foreach my $dep (@dep_buffer) {
343 my ($ds, $dt, $dhead, $dlabel) = @$dep;
344 $parse .= qq@<span id="s${ds}_n$dt" from="$spansFrom[$dt]" to="$spansTo[$dt]">
345<rel label="$dlabel">
346<span from="$spansFrom[$dhead]" to="$spansTo[$dhead]"/>
347</rel>
348</span>
349@;
350 }
351 @dep_buffer = ();
352}
353
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100354sub closeDoc {
Akronf3efc9e2026-06-03 10:46:37 +0200355 flush_dep_buffer();
Akron49f333b2022-09-27 17:03:49 +0200356 if ($write_morpho && $morpho_file) {
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100357 newZipStream($morpho_file);
358 $zip->print($morpho, qq( </spanList>\n</layer>\n));
359 }
Akron49f333b2022-09-27 17:03:49 +0200360 if ($write_syntax && $parser_file) {
Marc Kupietz79ba1e52021-02-12 17:26:54 +0100361 $write_syntax = 0;
362 newZipStream($parser_file);
363 $zip->print($parse, qq(</spanList>\n</layer>\n));
364 }
365}
366
367sub layer_header {
368 my ($docid) = @_;
369 return(qq(<?xml version="1.0" encoding="UTF-8"?>
370<?xml-model href="span.rng" type="application/xml" schematypens="http://relaxng.org/ns/structure/1.0"?>
371<layer docid="$docid" xmlns="http://ids-mannheim.de/ns/KorAP" version="KorAP-0.4">
372<spanList>
373));
Marc Kupietzaeb84a02021-10-11 17:57:29 +0200374}
375
376=pod
377
378=encoding utf8
379
380=head1 NAME
381
382conllu2korapxml - Conversion of KorAP-XML CoNLL-U to KorAP-XML zips
383
384=head1 SYNOPSIS
385
386 conllu2korapxml < zca15.tree_tagger.conllu > zca15.tree_tagger.zip
387
388=head1 DESCRIPTION
389
390C<conllu2korapxml> converts CoNLL-U files that follow KorAP-specific comment conventions
391 and contain morphosyntactic and/or dependency annotations to
392 corresponding KorAP-XML zip files.
393
394=head1 INSTALLATION
395
396 $ cpanm https://github.com/KorAP/KorAP-XML-CoNLL-U.git
397
398=head1 OPTIONS
399
400=over 2
401
402=item B<--force-foundry|-f>
403
404Set foundry name and ignore foundry names in the input.
405
Akron8dabdc12026-06-02 16:36:07 +0200406=item B<--text-sigle|-s>
407
408Text sigle template with C<{ID}> placeholder for standard UD CoNLL-U
409input. When C<# newdoc id = E<lt>IDE<gt>> is encountered, C<{ID}> is
410replaced by the document id to derive the KorAP-XML text sigle,
411filename, and docid.
Marc Kupietzdd546a82024-03-22 16:30:09 +0100412
Marc Kupietzaeb84a02021-10-11 17:57:29 +0200413=item B<--help|-h>
414
415Print help information.
416
417=item B<--version|-v>
418
419Print version information.
420
421
422=item B<--log|-l>
423
424Loglevel for I<Log::Any>. Defaults to C<warn>.
425
Marc Kupietz187abd72024-06-25 14:30:01 +0200426=item B<--output|-o>
427
428Output file. Defaults to C<-> (stdout).
429
Marc Kupietzaeb84a02021-10-11 17:57:29 +0200430=back
431
432=head1 EXAMPLES
433
434 conllu2korapxml -f tree_tagger < t/data/wdf19.morpho.conllu > wdf19.tree_tagger.zip
435
Marc Kupietzdd546a82024-03-22 16:30:09 +0100436 conllu2korapxml -f "tree_tagger dependency:malt" < t/data/wdf19.tt-malt.conllu > wdf19.tree_tagger.zip
437
Marc Kupietzaeb84a02021-10-11 17:57:29 +0200438=head1 COPYRIGHT AND LICENSE
439
Akron249fc832024-06-04 16:36:44 +0200440Copyright (C) 2021-2024, L<IDS Mannheim|https://www.ids-mannheim.de/>
Marc Kupietzaeb84a02021-10-11 17:57:29 +0200441
442Author: Marc Kupietz
443
444Contributors: Nils Diewald
445
446L<KorAP::XML::CoNNL-U> is developed as part of the L<KorAP|https://korap.ids-mannheim.de/>
447Corpus Analysis Platform at the
448L<Leibniz Institute for the German Language (IDS)|http://ids-mannheim.de/>,
449member of the
450L<Leibniz-Gemeinschaft|http://www.leibniz-gemeinschaft.de/>.
451
452This program is free software published under the
453L<BSD-2 License|https://opensource.org/licenses/BSD-2-Clause>.