1 | # Copyright (C) 2013-2021 Alexis Bienvenüe <paamc@passoire.fr>
|
2 | #
|
3 | # This file is part of Auto-Multiple-Choice
|
4 | #
|
5 | # Auto-Multiple-Choice is free software: you can redistribute it
|
6 | # and/or modify it under the terms of the GNU General Public License
|
7 | # as published by the Free Software Foundation, either version 2 of
|
8 | # the License, or (at your option) any later version.
|
9 | #
|
10 | # Auto-Multiple-Choice is distributed in the hope that it will be
|
11 | # useful, but WITHOUT ANY WARRANTY; without even the implied warranty
|
12 | # of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
13 | # General Public License for more details.
|
14 | #
|
15 | # You should have received a copy of the GNU General Public License
|
16 | # along with Auto-Multiple-Choice. If not, see
|
17 | # <http://www.gnu.org/licenses/>.
|
18 |
|
19 | use warnings;
|
20 | use 5.012;
|
21 |
|
22 | package AMC::Annotate;
|
23 |
|
24 | use Gtk3;
|
25 | use List::Util qw(min max sum);
|
26 | use File::Copy;
|
27 | use Unicode::Normalize;
|
28 | use File::Temp qw/ tempfile /;
|
29 | use Text::CSV;
|
30 |
|
31 | use AMC::Path;
|
32 | use AMC::Basic;
|
33 | use AMC::Export;
|
34 | use AMC::Subprocess;
|
35 | use AMC::NamesFile;
|
36 | use AMC::Substitute;
|
37 | use AMC::DataModule::report ':const';
|
38 | use AMC::DataModule::capture qw/:zone :position/;
|
39 | use AMC::DataModule::layout qw/:flags/;
|
40 | use AMC::Gui::Avancement;
|
41 | use AMC::Calage;
|
42 | use AMC::Boite;
|
43 |
|
44 | use utf8;
|
45 |
|
46 | sub new {
|
47 | my (%o) = (@_);
|
48 |
|
49 | my $self = {
|
50 | data_dir => '',
|
51 | project_dir => '',
|
52 | projects_dir => '',
|
53 | pdf_dir => '',
|
54 | single_output => '',
|
55 | filename_model => '(N)-(ID)',
|
56 | force_ascii => '',
|
57 | pdf_subject => '',
|
58 | names_file => '',
|
59 | names_encoding => 'utf8',
|
60 | association_key => '',
|
61 | csv_build_name => '',
|
62 | anonymous => '',
|
63 | header_only => '',
|
64 | significant_digits => 1,
|
65 | darkness_threshold => '',
|
66 | darkness_threshold_up => '',
|
67 | id_file => '',
|
68 | sort => '',
|
69 | annotate_indicatives => '',
|
70 | position => 'marges',
|
71 | text_color => 'red',
|
72 | line_width => 1,
|
73 | font_name => 'Linux Libertine O 12',
|
74 | dist_to_box => '1cm',
|
75 | dist_margin => '5mm',
|
76 | dist_margin_globaltext => '3mm',
|
77 | symbols => {
|
78 | '0-0' => {qw/type none/},
|
79 | '0-1' => {qw/type circle color red/},
|
80 | '1-0' => {qw/type mark color red/},
|
81 | '1-1' => {qw/type mark color blue/},
|
82 | },
|
83 | verdict => '',
|
84 | verdict_question => '',
|
85 | verdict_question_cancelled => '',
|
86 | progress => '',
|
87 | progress_id => '',
|
88 | compose => 0,
|
89 | pdf_corrected => '',
|
90 | pdf_background => '',
|
91 | changes_only => '',
|
92 | embedded_max_size => '',
|
93 | embedded_format => 'jpeg',
|
94 | embedded_jpeg_quality => 80,
|
95 | rtl => '',
|
96 | debug => ( get_debug() ? 1 : 0 ),
|
97 | };
|
98 |
|
99 | for my $k ( keys %o ) {
|
100 | $self->{$k} = $o{$k} if ( defined( $self->{$k} ) );
|
101 | }
|
102 |
|
103 | $self->{type} = (
|
104 | $self->{single_output}
|
105 | ? REPORT_SINGLE_ANNOTATED_PDF
|
106 | : REPORT_ANNOTATED_PDF
|
107 | );
|
108 | $self->{type} = REPORT_ANONYMIZED_PDF if ( $self->{anonymous} );
|
109 | $self->{loaded_pdf} = '';
|
110 |
|
111 | # checks that the position option is available
|
112 | $self->{position} = lc( $self->{position} );
|
113 | if ( $self->{position} !~ /^(marges?|case|zones|none)$/i ) {
|
114 | debug "ERROR: invalid \<position>: $self->{position}";
|
115 | $self->{position} = 'none';
|
116 | }
|
117 |
|
118 | # chacks that the embedded_format is ok
|
119 | $self->{embedded_format} = lc( $self->{embedded_format} );
|
120 | if ( $self->{embedded_format} !~ /^(jpeg|png)$/i ) {
|
121 | debug "ERROR: invalid <embedded_format>: $self->{embedded_format}";
|
122 | $self->{embedded_format} = 'jpeg';
|
123 | }
|
124 |
|
125 | # checks that the pdf files exist
|
126 | for my $k (qw/subject corrected/) {
|
127 | if ( $self->{ 'pdf_' . $k } && !-f $self->{ 'pdf_' . $k } ) {
|
128 | debug "WARNING: PDF $k file not found: " . $self->{ 'pdf_' . $k };
|
129 | $self->{ 'pdf_' . $k } = '';
|
130 | }
|
131 | }
|
132 |
|
133 | # force to default value when filename model is empty
|
134 | $self->{filename_model} = '(N)-(ID)'
|
135 | if ( $self->{filename_model} eq '' );
|
136 |
|
137 | # adds pdf extension if not already there
|
138 | if ( $self->{filename_model} !~ /\.pdf$/i ) {
|
139 | debug "Adding pdf extension to $self->{filename_model}";
|
140 | $self->{filename_model} .= '.pdf';
|
141 | }
|
142 |
|
143 | # if the corrected answer sheet is not given, use the subject
|
144 | # instead.
|
145 | if ( $self->{compose} == 2 && !-f $self->{pdf_corrected} ) {
|
146 | $self->{compose} = 1;
|
147 | }
|
148 |
|
149 | # which pdf file will be used as a background when scans are not
|
150 | # available?
|
151 | if ( $self->{compose} == 1 ) {
|
152 | $self->{pdf_background} = $self->{pdf_subject};
|
153 | } elsif ( $self->{compose} == 2 ) {
|
154 | $self->{pdf_background} = $self->{pdf_corrected};
|
155 | }
|
156 |
|
157 | # set up the object to send progress to calling program
|
158 | $self->{avance} =
|
159 | AMC::Gui::Avancement::new( $self->{progress}, id => $self->{progress_id} )
|
160 | if ( $self->{progress} );
|
161 |
|
162 | bless $self;
|
163 | return ($self);
|
164 | }
|
165 |
|
166 | # units conversion
|
167 |
|
168 | my %units = (
|
169 | in => 1,
|
170 | ft => 12,
|
171 | yd => 36,
|
172 | pt => 1 / 72,
|
173 | cm => 1 / 2.54,
|
174 | mm => 1 / 25.4,
|
175 | m => 1000 / 25.4,
|
176 | );
|
177 |
|
178 | sub dim2in {
|
179 | my ($d) = @_;
|
180 | UNITS: for my $u ( keys %units ) {
|
181 | if ( $d =~ /^(.*)(?<![a-zA-Z])$u$/ ) {
|
182 | $d = $1 * $units{$u};
|
183 | }
|
184 | }
|
185 | return ($d);
|
186 | }
|
187 |
|
188 | # get absolute path from a path that can contain %PROJECT or %PROJECTS
|
189 | # strings, that refer to the project directory and the projetcs
|
190 | # directory.
|
191 |
|
192 | sub absolute_path {
|
193 | my ( $self, $path ) = @_;
|
194 | if ( $self->{project_dir} ) {
|
195 | $path = proj2abs(
|
196 | {
|
197 | '%PROJET', $self->{project_dir},
|
198 | '%PROJETS', $self->{projects_dir},
|
199 | '%HOME' => $ENV{HOME},
|
200 | },
|
201 | $path
|
202 | );
|
203 | }
|
204 | return ($path);
|
205 | }
|
206 |
|
207 | # Tests if the report that has already been made is still present and
|
208 | # up to date. If up-to-date, returns the filename. Otherwise, returns
|
209 | # the empty string.
|
210 |
|
211 | sub student_uptodate {
|
212 | my ( $self, $student ) = @_;
|
213 |
|
214 | my ( $filename, $timestamp ) =
|
215 | $self->{report}->get_student_report_time(
|
216 | ( $self->{anonymous} ? REPORT_ANONYMIZED_PDF : REPORT_ANNOTATED_PDF ),
|
217 | @$student );
|
218 |
|
219 | if ($filename) {
|
220 | debug "Registered filename " . show_utf8($filename);
|
221 | my $source_change =
|
222 | $self->{capture}->variable('annotate_source_change');
|
223 | debug
|
224 | "Registered answer sheet: updated at $timestamp, source change at $source_change";
|
225 |
|
226 | # we say there is an up-to-date annotated answer sheet if the file
|
227 | # exists and has been built after the last time some result or
|
228 | # configuration variable were changed.
|
229 | debug "Directory " . show_utf8( $self->{pdf_dir} );
|
230 | debug "Looking for filename " . show_utf8($filename);
|
231 | my $path = "$self->{pdf_dir}/$filename";
|
232 | if ( -f $path && $timestamp > $source_change ) {
|
233 | debug "Exists!";
|
234 | return ($filename);
|
235 | } else {
|
236 | debug "NOT up-to-date.";
|
237 | }
|
238 | } else {
|
239 | debug "No registered annotated answer sheet.";
|
240 | }
|
241 | return ('');
|
242 | }
|
243 |
|
244 | # Computes the filename to be used for the student annotated answer
|
245 | # sheet. Returns this filename, and, if there is already a up-to-date
|
246 | # annotated answer sheet, also returns the name of this one.
|
247 |
|
248 | sub pdf_output_filename {
|
249 | my ( $self, $student ) = @_;
|
250 |
|
251 | $self->needs_data;
|
252 | $self->needs_names;
|
253 |
|
254 | my $f = $self->{filename_model};
|
255 |
|
256 | debug "F[0]=$f";
|
257 |
|
258 | # computes student/copy four digits ID and substitutes (N) with it
|
259 | my $ex;
|
260 | if ( $student->[1] ) {
|
261 | $ex = sprintf( "%04d:%04d", @$student );
|
262 | } else {
|
263 | $ex = sprintf( "%04d", $student->[0] );
|
264 | }
|
265 | $f =~ s/\(N\)/$ex/gi;
|
266 |
|
267 | debug "F[N]=" . show_utf8($f);
|
268 |
|
269 | # Substitute (aID) with anonymous ID
|
270 |
|
271 | if ( $self->{anonymous} && $f =~ /\(aID\)/ ) {
|
272 | $self->{association}->begin_transaction('anon');
|
273 | my $aid = $self->{association}
|
274 | ->anonymized( $student->[0], $student->[1], $self->{anonymous} );
|
275 | $f =~ s/\(aID\)/$aid/g;
|
276 | $self->{association}->end_transaction('anon');
|
277 | }
|
278 |
|
279 | # get student data from the students list file, and substitutes
|
280 | # into filename
|
281 | if ( $self->{names} ) {
|
282 | $self->{data}->begin_read_transaction('rAGN');
|
283 | my $i = $self->{association}->get_real(@$student);
|
284 | $self->{data}->end_transaction('rAGN');
|
285 |
|
286 | my $name = 'XXX';
|
287 | my $n;
|
288 |
|
289 | debug "Association -> ID=$i";
|
290 |
|
291 | if ( defined($i) ) {
|
292 | debug "Looking for student $self->{association_key} = $i";
|
293 | ($n) = $self->{names}
|
294 | ->data( $self->{association_key}, $i, test_numeric => 1 );
|
295 | if ($n) {
|
296 | debug "Found";
|
297 | $f = $self->{names}->substitute( $n, $f );
|
298 | }
|
299 | }
|
300 |
|
301 | debug "F[n]=" . show_utf8($f);
|
302 |
|
303 | } else {
|
304 | $f =~ s/-?\(ID\)//gi;
|
305 | }
|
306 |
|
307 | # Substitute all spaces and non-ascii characters from the file name
|
308 | # if the user asked so.
|
309 |
|
310 | if ( $self->{force_ascii} ) {
|
311 | $f = string_to_filename( $f, 'copy' );
|
312 | debug "F[a]=" . show_utf8($f);
|
313 | }
|
314 |
|
315 | # The filename we would like to use id $f, but now we have to check
|
316 | # it is not already used for another annotated file... and register
|
317 | # it.
|
318 |
|
319 | $self->{data}->begin_transaction('rSST');
|
320 |
|
321 | # check if there is already an up-to-date annotated answer sheet for
|
322 | # this student BEFORE removing the entry from the database (and
|
323 | # recall this filename).
|
324 |
|
325 | my $uptodate_filename = '';
|
326 | if ( $self->{changes_only} ) {
|
327 | $uptodate_filename = $self->student_uptodate($student);
|
328 | }
|
329 |
|
330 | # delete the entry from the database, and build a filename that is
|
331 | # not already registered for another student (the same or similar to
|
332 | # $f).
|
333 |
|
334 | $self->{report}->delete_student_report( $self->{type}, @$student );
|
335 | $f = $self->{report}->free_student_report( $self->{type}, $f );
|
336 | $self->{report}->set_student_report( $self->{type}, @$student, $f, 'now' );
|
337 |
|
338 | $self->{data}->end_transaction('rSST');
|
339 |
|
340 | debug "F[R]=" . show_utf8($f);
|
341 |
|
342 | return ( $f, $uptodate_filename );
|
343 | }
|
344 |
|
345 | sub connects_to_database {
|
346 | my ($self) = @_;
|
347 |
|
348 | # Open connections to the SQLite databases that we will use.
|
349 |
|
350 | $self->{data} = AMC::Data->new( $self->{data_dir} );
|
351 | for my $m (qw/layout capture association scoring report/) {
|
352 | $self->{$m} = $self->{data}->module($m);
|
353 | }
|
354 |
|
355 | # If they are not already given by the user, read association_key
|
356 | # and darkness_threshold from the variables in the database.
|
357 |
|
358 | $self->{association_key} =
|
359 | $self->{association}->variable_transaction('key_in_list')
|
360 | if( !$self->{association_key} );
|
361 | $self->{darkness_threshold} =
|
362 | $self->{scoring}->variable_transaction('darkness_threshold')
|
363 | if ( !$self->{darkness_threshold} );
|
364 | $self->{darkness_threshold_up} =
|
365 | $self->{scoring}->variable_transaction('darkness_threshold_up')
|
366 | if ( !$self->{darkness_threshold_up} );
|
367 |
|
368 | # But darkness_threshold_up is not defined for old projects… set it
|
369 | # to an inactive value in this case
|
370 |
|
371 | $self->{darkness_threshold_up} = 1.0 if ( !$self->{darkness_threshold_up} );
|
372 | }
|
373 |
|
374 | sub error {
|
375 | my ( $self, $message ) = @_;
|
376 |
|
377 | debug_and_stderr("**ERROR** $message");
|
378 | }
|
379 |
|
380 | sub needs_data {
|
381 | my ($self) = @_;
|
382 |
|
383 | if ( !$self->{data} ) {
|
384 | $self->connects_to_database;
|
385 | }
|
386 | }
|
387 |
|
388 | sub connects_students_list {
|
389 | my ($self) = @_;
|
390 |
|
391 | $self->needs_data();
|
392 |
|
393 | # If given, opens the students list and read it.
|
394 |
|
395 | if ( -f $self->{names_file} ) {
|
396 | $self->{names} = AMC::NamesFile::new(
|
397 | $self->{names_file},
|
398 | encodage => $self->{names_encoding},
|
399 | identifiant => $self->{csv_build_name}
|
400 | );
|
401 |
|
402 | debug "Keys in names file: " . join( ", ", $self->{names}->heads() );
|
403 | } else {
|
404 | $self->{names} = '';
|
405 | if ( $self->{names_file} ) {
|
406 | debug "No names file";
|
407 | } else {
|
408 | debug "Names file not found: $self->{names_file}";
|
409 | }
|
410 | }
|
411 |
|
412 | # Set up a AMC::Substitute object that will be used to substitute
|
413 | # marks, student name, and so on in the verdict strings for question
|
414 | # scores and global header.
|
415 |
|
416 | $self->{subst} = AMC::Substitute::new(
|
417 | names => $self->{names},
|
418 | scoring => $self->{scoring},
|
419 | assoc => $self->{association},
|
420 | name => '',
|
421 | chsign => $self->{significant_digits},
|
422 | anonymous => $self->{anonymous},
|
423 | );
|
424 | }
|
425 |
|
426 | sub needs_names {
|
427 | my ($self) = @_;
|
428 |
|
429 | if ( !$self->{subst} ) {
|
430 | $self->connects_students_list;
|
431 | }
|
432 | }
|
433 |
|
434 | # get a sorted list of all students, using AMC::Export
|
435 |
|
436 | sub compute_sorted_students_list {
|
437 | my ($self) = @_;
|
438 |
|
439 | if ( !$self->{sorted_students} ) {
|
440 |
|
441 | # Use AMC::Export that can do the work for us...
|
442 |
|
443 | my $sorted_students = AMC::Export->new();
|
444 | $sorted_students->set_options(
|
445 | 'fich',
|
446 | datadir => $self->{data_dir},
|
447 | noms => $self->{names_file}
|
448 | );
|
449 | $sorted_students->set_options(
|
450 | 'noms',
|
451 | encodage => $self->{names_encoding},
|
452 | useall => 0
|
453 | );
|
454 | $sorted_students->set_options( 'sort', keys => $self->{sort} );
|
455 | $sorted_students->pre_process();
|
456 |
|
457 | $self->{sorted_students} = $sorted_students;
|
458 | }
|
459 | }
|
460 |
|
461 | # sort the students so that they are ordered as in the sorted_students
|
462 | # list
|
463 |
|
464 | sub sort_students {
|
465 | my ($self) = @_;
|
466 |
|
467 | $self->compute_sorted_students_list();
|
468 | my %include =
|
469 | map { studentids_string(@$_) => 1 } ( @{ $self->{students} } );
|
470 | $self->{students} = [
|
471 | map { [ $_->{student}, $_->{copy} ] }
|
472 | grep { $include{ studentids_string( $_->{student}, $_->{copy} ) } }
|
473 | ( @{ $self->{sorted_students}->{marks} } )
|
474 | ];
|
475 |
|
476 | }
|
477 |
|
478 | # get the students to process from a file and return the number of
|
479 | # students
|
480 |
|
481 | sub get_students_from_file {
|
482 | my ($self) = @_;
|
483 | my @students;
|
484 |
|
485 | # loads a list of students from a plain text file (one per line)
|
486 | if ( -f $self->{id_file} ) {
|
487 | my @students;
|
488 | open( NUMS, $self->{id_file} );
|
489 | while (<NUMS>) {
|
490 | if (/^([0-9]+):([0-9]+)$/) {
|
491 | push @students, [ $1, $2 ];
|
492 | } elsif (/^([0-9]+)$/) {
|
493 | push @students, [ $1, 0 ];
|
494 | }
|
495 | }
|
496 | close(NUMS);
|
497 |
|
498 | $self->{students} = \@students;
|
499 | return ( 1 + $#students );
|
500 | } else {
|
501 | return (0);
|
502 | }
|
503 | }
|
504 |
|
505 | # get the students to process from capture data (all students that
|
506 | # have some data capture -- scan or manual -- on at least one page)
|
507 |
|
508 | sub get_students_from_data {
|
509 | my ($self) = @_;
|
510 |
|
511 | $self->needs_data;
|
512 |
|
513 | $self->{capture}->begin_read_transaction('gast');
|
514 | $self->{students} = $self->{capture}
|
515 | ->dbh->selectall_arrayref( $self->{capture}->statement('studentCopies') );
|
516 | $self->{capture}->end_transaction('gast');
|
517 |
|
518 | return ( 1 + $#{ $self->{students} } );
|
519 | }
|
520 |
|
521 | # get the students to process
|
522 |
|
523 | sub get_students {
|
524 | my ($self) = @_;
|
525 |
|
526 | my $n = $self->get_students_from_file
|
527 | || $self->get_students_from_data;
|
528 |
|
529 | # sort this list if we are going to make an unique annotated
|
530 | # file with all the students' copies (and if a sort key is given)
|
531 | if ( $n > 1 && $self->{single_output} && $self->{sort} ) {
|
532 | $self->sort_students();
|
533 | }
|
534 |
|
535 | debug "Number of students to process: $n";
|
536 |
|
537 | return ($n);
|
538 | }
|
539 |
|
540 | # get dimensions of a subject page
|
541 |
|
542 | sub get_dimensions {
|
543 | my ($self) = @_;
|
544 |
|
545 | $self->needs_data;
|
546 |
|
547 | # get width, height and DPI from a subject page (these values should
|
548 | # be the same for all pages).
|
549 |
|
550 | $self->{data}->begin_read_transaction("aDIM");
|
551 |
|
552 | ( $self->{width}, $self->{height}, undef, $self->{dpi} ) =
|
553 | $self->{layout}->dims( $self->{layout}->random_studentPage );
|
554 |
|
555 | $self->{data}->end_transaction("aDIM");
|
556 |
|
557 | # Now, convert all dist_* lenghts to a number of points.
|
558 |
|
559 | if ( !$self->{unit_pixels} ) {
|
560 | for my $dd ( map { \$self->{ 'dist_' . $_ } }
|
561 | (qw/to_box margin margin_globaltext/) )
|
562 | {
|
563 | $$dd = dim2in($$dd);
|
564 | }
|
565 | $self->{unit_pixels} = 1;
|
566 | }
|
567 | }
|
568 |
|
569 | sub needs_dims {
|
570 | my ($self) = @_;
|
571 |
|
572 | if ( !$self->{dpi} ) {
|
573 | $self->get_dimensions;
|
574 | }
|
575 | }
|
576 |
|
577 | # subprocess (call to AMC-buildpdf) initialisation
|
578 |
|
579 | sub process_start {
|
580 | my ($self) = @_;
|
581 |
|
582 | $self->needs_dims;
|
583 |
|
584 | $self->{process} = AMC::Subprocess::new(
|
585 | mode => 'buildpdf',
|
586 | 'args' =>
|
587 | [ '-d', $self->{dpi}, '-w', $self->{width}, '-h', $self->{height} ]
|
588 | );
|
589 | $self->command( "embedded " . $self->{embedded_format} );
|
590 | if ( $self->{embedded_max_size} =~ /([0-9]*)x([0-9]*)/i ) {
|
591 | my $width = $1;
|
592 | my $height = $2;
|
593 | $self->command( "max width " . ( $width ? $width : 0 ) );
|
594 | $self->command( "max height " . ( $height ? $height : 0 ) );
|
595 | }
|
596 | $self->command( "jpeg quality " . $self->{embedded_jpeg_quality} );
|
597 | $self->command( "margin " . $self->{dist_margin} );
|
598 | $self->command("debug") if ( $self->{debug} );
|
599 | }
|
600 |
|
601 | # send a command to the subprocess
|
602 |
|
603 | sub command {
|
604 | my ( $self, @command ) = @_;
|
605 | $self->{process}->commande(@command);
|
606 | }
|
607 |
|
608 | # Sends a (maybe multi-line) text to AMC-buildpdf to be used in the
|
609 | # following command.
|
610 |
|
611 | sub stext {
|
612 | my ( $self, $text ) = @_;
|
613 | $self->command("stext begin\n$text\n__END__");
|
614 | }
|
615 |
|
616 | # gets RGB values (from 0.0 to 1.0) from color text description
|
617 |
|
618 | sub color_rgb {
|
619 | my ($s) = @_;
|
620 | my $col = Gtk3::Gdk::Color::parse($s);
|
621 | if ($col) {
|
622 | return ( $col->red / 65535, $col->green / 65535, $col->blue / 65535 );
|
623 | } else {
|
624 | debug "Color parse error: $col";
|
625 | return ( .5, .5, .5 );
|
626 | }
|
627 | }
|
628 |
|
629 | # set color for drawing
|
630 |
|
631 | sub set_color {
|
632 | my ( $self, $color_string ) = @_;
|
633 | $self->command( join( ' ', "color", color_rgb($color_string) ) );
|
634 | }
|
635 |
|
636 | # inserts a page from a pdf file
|
637 |
|
638 | sub insert_pdf_page {
|
639 | my ( $self, $pdf_path, $page ) = @_;
|
640 |
|
641 | if ( $pdf_path ne $self->{loaded_pdf} ) {
|
642 |
|
643 | # If this PDF file is not already loaded by AMC-buildpdf, load it.
|
644 | $self->command("load pdf $pdf_path");
|
645 | $self->{loaded_pdf} = $pdf_path;
|
646 | }
|
647 | $self->command("page pdf $page");
|
648 | }
|
649 |
|
650 | # get a list of pages for a particular student
|
651 |
|
652 | sub student_pages {
|
653 | my ( $self, $student ) = @_;
|
654 | return (
|
655 | $self->{layout}->pages_info_for_student( $student->[0], enter_tag => 1 )
|
656 | );
|
657 | }
|
658 |
|
659 | # Inserts the background for an annotated page. Returns:
|
660 | #
|
661 | # -1 if no page were inserted (without compose option, or when the
|
662 | # page from the subject is not available)
|
663 | #
|
664 | # 0 if a scan is used
|
665 | #
|
666 | # 1 if a subject page with no answer boxes is used
|
667 | #
|
668 | # 2 if a subject page with answer boxes is used
|
669 |
|
670 | sub page_background {
|
671 | my ( $self, $student, $page ) = @_;
|
672 |
|
673 | # First get the scan, if available...
|
674 |
|
675 | my $page_capture =
|
676 | $self->{capture}->get_page( $student->[0], $page->{page}, $student->[1] )
|
677 | || {};
|
678 | my $scan = '';
|
679 |
|
680 | $scan = $self->absolute_path( $page_capture->{src} )
|
681 | if ( $page_capture->{src} );
|
682 |
|
683 | if ( -f $scan ) {
|
684 |
|
685 | # Anonymous mode : erease scan parts where the name, ID, and
|
686 | # so on can be found
|
687 |
|
688 | my $tmp_scan = '';
|
689 |
|
690 | if ( $self->{anonymous} ) {
|
691 | debug "Anonymize [$student->[0],$page->{page}] $scan ...";
|
692 | my @idzones =
|
693 | $self->{layout}->type_info( 'idzone', $student->[0], $page->{page} );
|
694 | if (@idzones) {
|
695 | debug "Anonymize ".(1+$#idzones)." zones";
|
696 | my $fh;
|
697 | my $t = AMC::Calage::new;
|
698 | $t->set( map { "t_".$_ => $page_capture->{$_} }
|
699 | (qw/a b c d e f/) );
|
700 | debug "* Anonymize $scan";
|
701 | ( $fh, $tmp_scan ) = tempfile();
|
702 | my $bg = magick_perl_module()->new();
|
703 | $bg->Read($scan);
|
704 | for my $z (@idzones) {
|
705 | my $box=AMC::Boite::new();
|
706 | $box->def_droite_MN(map { $z->{$_} } (qw/xmin ymin xmax ymax/));
|
707 | debug "> ".$box->contour();
|
708 | $box->transforme($t);
|
709 | $bg->Draw(primitive=>'polyline',
|
710 | fill=>'white',
|
711 | stroke=>'black',
|
712 | points=>$box->contour());
|
713 | $bg->Draw(primitive=>'line',
|
714 | stroke=>'blue',
|
715 | points=>$box->diag1());
|
716 | $bg->Draw(primitive=>'line',
|
717 | stroke=>'blue',
|
718 | points=>$box->diag2());
|
719 | }
|
720 | $bg->Write($tmp_scan);
|
721 | $scan = $tmp_scan;
|
722 | } else {
|
723 | debug "Nothing to anonymize";
|
724 | }
|
725 | }
|
726 |
|
727 | # If the scan is available, use it (with AMC-buildpdf "page png"
|
728 | # or "page img" command, depending on the file type). The matrix
|
729 | # that transforms coordinates from subject to scan has been
|
730 | # computed when automatic data capture was made. It is sent to
|
731 | # AMC-buildpdf.
|
732 |
|
733 | my $img_type = 'img';
|
734 | if ( AMC::Basic::file_mimetype($scan) eq 'image/png' ) {
|
735 | $img_type = 'png';
|
736 | }
|
737 | $self->command("page $img_type $scan");
|
738 | $self->command(
|
739 | join(
|
740 | ' ', "matrix", map { $page_capture->{$_} } (qw/a b c d e f/)
|
741 | )
|
742 | );
|
743 |
|
744 | if(-f $tmp_scan) {
|
745 | unlink($tmp_scan);
|
746 | }
|
747 |
|
748 | return (0);
|
749 | } else {
|
750 | if ($scan) {
|
751 | debug "WARNING: Registered scan \"$scan\" was not found.";
|
752 | }
|
753 |
|
754 | # If there is no scan,
|
755 | if ( $page->{enter} && -f $self->{pdf_subject} ) {
|
756 |
|
757 | # If the page contains something to be filled by the student
|
758 | # (either name field or boxes), inserts the page from the PDF
|
759 | # subject.
|
760 |
|
761 | debug "Using subject page.";
|
762 | $self->insert_pdf_page( $self->{pdf_subject},
|
763 | $page->{subjectpage} );
|
764 | $self->command("matrix identity");
|
765 |
|
766 | return (2);
|
767 | } else {
|
768 | if ( !$page->{enter} ) {
|
769 | debug "Page without fields.";
|
770 | }
|
771 |
|
772 | # With <compose> option, pages without anything to be filled
|
773 | # (only subject) are added, from the corrected PDF if available
|
774 | # (then the student will see the correct answers easily on the
|
775 | # annotated answer sheet).
|
776 |
|
777 | if ( -f $self->{pdf_background} ) {
|
778 | $self->insert_pdf_page( $self->{pdf_background},
|
779 | $page->{subjectpage} );
|
780 | return (1);
|
781 | }
|
782 | }
|
783 | return (-1);
|
784 | }
|
785 | }
|
786 |
|
787 | # draws one symbol. $b is one row from the capture:pageZones SQL query
|
788 | # (from which we use only the id_a=question, id_b=answer and role
|
789 | # attributes). When $tick is true, boxes are tickedas the student did
|
790 | # (this can be usefull for manual data capture for example, when the
|
791 | # background is not the scan but the PDF subject, and we want to
|
792 | # illustrate which boxes has been ticked by the student).
|
793 |
|
794 | sub draw_symbol {
|
795 | my ( $self, $student, $b, $tick ) = @_;
|
796 |
|
797 | my $p_strategy = $self->{scoring}->unalias( $student->[0] );
|
798 | my $q = $b->{id_a}; # question number
|
799 | my $r = $b->{id_b}; # answer number
|
800 | my $indic = $self->{scoring}->indicative( $p_strategy, $q )
|
801 | ; # is it an indicative question?
|
802 |
|
803 | # ticked on this scan?
|
804 | my $cochee = $self->{capture}->ticked(
|
805 | @$student, $q, $r,
|
806 | $self->{darkness_threshold},
|
807 | $self->{darkness_threshold_up}
|
808 | );
|
809 |
|
810 | # get box position on subject
|
811 | my $box =
|
812 | $self->{layout}->get_box_info( $student->[0], $q, $r, $b->{role} );
|
813 |
|
814 | # when the subject background is used instead of the scan, darken
|
815 | # boxes that have been ticked by the student
|
816 | if ( $tick && $cochee ) {
|
817 | debug "Tick.";
|
818 | $self->set_color('black');
|
819 | $self->command(
|
820 | join( ' ',
|
821 | ( $self->{darkness_threshold_up} < 1 ? 'mark' : 'fill' ),
|
822 | map { $box->{$_} } (qw/xmin xmax ymin ymax/) )
|
823 | );
|
824 | }
|
825 |
|
826 | return if ( $indic && !$self->{annotate_indicatives} );
|
827 |
|
828 | # to be ticked?
|
829 | my $bonne = $self->{scoring}->correct_answer( $p_strategy, $q, $r );
|
830 |
|
831 | debug "Q=$q R=$r $bonne-$cochee";
|
832 |
|
833 | # get symbol to draw
|
834 | my $sy = $self->{symbols}->{"$bonne-$cochee"};
|
835 |
|
836 | if ( $box->{flags} & BOX_FLAGS_DONTANNOTATE ) {
|
837 | debug "This box is flagged \"don't annotate\": skipping";
|
838 | } else {
|
839 | if ( $sy->{type} =~ /^(circle|mark|box)$/ ) {
|
840 |
|
841 | # tells AMC-buildpdf to draw the symbol with the right color
|
842 | $self->set_color( $sy->{color} );
|
843 | $self->command(
|
844 | join( ' ',
|
845 | $sy->{type}, map { $box->{$_} } (qw/xmin xmax ymin ymax/) )
|
846 | );
|
847 | } elsif ( $sy->{type} eq 'none' ) {
|
848 | } else {
|
849 | debug "Unknown symbol type ($bonne-$cochee): $sy->{type}";
|
850 | }
|
851 | }
|
852 |
|
853 | # records box position so that question scores can be
|
854 | # well-positioned
|
855 |
|
856 | $self->{question}->{$q} = {} if ( !$self->{question}->{$q} );
|
857 | push @{ $self->{question}->{$q}->{x} }, ( $box->{xmin} + $box->{xmax} ) / 2;
|
858 | push @{ $self->{question}->{$q}->{y} }, ( $box->{ymin} + $box->{ymax} ) / 2;
|
859 | }
|
860 |
|
861 | # draws symbols on one page
|
862 |
|
863 | sub page_symbols {
|
864 | my ( $self, $student, $page, $tick ) = @_;
|
865 |
|
866 | # goes through all the boxes on the page
|
867 |
|
868 | # the question boxes (in separate answer sheet mode)
|
869 | if ( $self->{compose} == 1 ) {
|
870 | my $sth = $self->{layout}->statement('pageQuestionBoxes');
|
871 | $sth->execute( $student->[0], $page );
|
872 | while ( my $box = $sth->fetchrow_hashref ) {
|
873 | $self->draw_symbol( $student, $box, 1 );
|
874 | }
|
875 | }
|
876 |
|
877 | # the answer boxes that were captured
|
878 | my $sth = $self->{capture}->statement('pageZones');
|
879 | $sth->execute( $student->[0], $page, $student->[1], ZONE_BOX );
|
880 | while ( my $box = $sth->fetchrow_hashref ) {
|
881 | $self->draw_symbol( $student, $box, $tick );
|
882 | }
|
883 | }
|
884 |
|
885 | # computes the score text for a particular question
|
886 |
|
887 | sub qtext {
|
888 | my ( $self, $student, $question ) = @_;
|
889 |
|
890 | my $result = $self->{scoring}->question_result( @$student, $question );
|
891 |
|
892 | my $text;
|
893 |
|
894 | # begins with the right verdict version depending on if the question
|
895 | # result was cancelled or not.
|
896 |
|
897 | if ( $result->{why} =~ /c/i ) {
|
898 | $text = $self->{verdict_question_cancelled};
|
899 | } else {
|
900 | $text = $self->{verdict_question};
|
901 | }
|
902 |
|
903 | # substitute scores values
|
904 |
|
905 | $text =~ s/\%[S]/$result->{score}/g;
|
906 | $text =~ s/\%[M]/$result->{max}/g;
|
907 | $text =~ s/\%[W]/$result->{why}/g;
|
908 | $text =~ s/\%[s]/$self->{subst}->format_note($result->{score})/ge;
|
909 | $text =~ s/\%[m]/$self->{subst}->format_note($result->{max})/ge;
|
910 |
|
911 | # evaluates the result
|
912 |
|
913 | my $te = eval($text);
|
914 | if ($@) {
|
915 | debug "Annotation: $text";
|
916 | debug "Evaluation error $@";
|
917 | } else {
|
918 | $text = $te;
|
919 | }
|
920 |
|
921 | return ($text);
|
922 | }
|
923 |
|
924 | # mean of the y positions of the boxes for one question
|
925 |
|
926 | sub q_ymean {
|
927 | my ( $self, $q ) = @_;
|
928 |
|
929 | return ( sum( @{ $self->{question}->{$q}->{y} } ) /
|
930 | ( 1 + $#{ $self->{question}->{$q}->{y} } ) );
|
931 | }
|
932 |
|
933 | # where to write question status?
|
934 |
|
935 | # 1) scores written in the left margin
|
936 | sub qtext_position_marge {
|
937 | my ( $self, $student, $page, $question ) = @_;
|
938 |
|
939 | my $y = $self->q_ymean($question);
|
940 |
|
941 | if ( $self->{rtl} ) {
|
942 | return ("stext margin 1 $y 1 0.5");
|
943 | } else {
|
944 | return ("stext margin 0 $y 0 0.5");
|
945 | }
|
946 | }
|
947 |
|
948 | # 2) scores written in one of the margins (left or right), depending
|
949 | # on the position of the boxes. This mode is often used when the
|
950 | # subject is in a 2-column layout.
|
951 | sub qtext_position_marges {
|
952 | my ( $self, $student, $page, $q ) = @_;
|
953 |
|
954 | # fist extract the y coordinates of the boxes in the left column
|
955 | my $left = 1;
|
956 | my @y = map { $self->{question}->{$q}->{y}->[$_] }
|
957 | grep {
|
958 | $self->{rtl}
|
959 | xor( $self->{question}->{$q}->{x}->[$_] <= $self->{width} / 2 )
|
960 | } ( 0 .. $#{ $self->{question}->{$q}->{x} } );
|
961 | if ( !@y ) {
|
962 |
|
963 | # if empty, use the right column
|
964 | $left = 0;
|
965 | @y = map { $self->{question}->{$q}->{y}->[$_] }
|
966 | grep {
|
967 | $self->{rtl}
|
968 | xor( $self->{question}->{$q}->{x}->[$_] > $self->{width} / 2 )
|
969 | } ( 0 .. $#{ $self->{question}->{$q}->{x} } );
|
970 | }
|
971 |
|
972 | # set the x-position to the left or right margin
|
973 | my $jx = 1;
|
974 | $jx = 0 if ( $left xor $self->{rtl} );
|
975 |
|
976 | # set the y-position to the mean of y coordinates of the
|
977 | # boxes in the corresponding column
|
978 | my $y = sum(@y) / ( 1 + $#y );
|
979 |
|
980 | return ("stext margin $jx $y $jx 0.5");
|
981 | }
|
982 |
|
983 | # 3) scores written at the side of all the boxes
|
984 | sub qtext_position_case {
|
985 | my ( $self, $student, $page, $q ) = @_;
|
986 |
|
987 | my $x = max( @{ $self->{question}->{$q}->{x} } ) +
|
988 | ( $self->{rtl} ? 1 : -1 ) * $self->{dist_to_box} * $self->{dpi};
|
989 | my $y = $self->q_ymean($q);
|
990 | return ("stext $x $y 0 0.5");
|
991 | }
|
992 |
|
993 | # 4) scores written in the zone defined by the source file
|
994 | sub qtext_position_zones {
|
995 | my ( $self, $student, $page, $q ) = @_;
|
996 | my @c = ();
|
997 | for my $b ( $self->{layout}->score_zones( $student->[0], $page, $q ) ) {
|
998 | push @c, "stext rectangle "
|
999 | . join( " ", map { $b->{$_} } (qw/xmin xmax ymin ymax/) );
|
1000 | }
|
1001 | return ( \@c );
|
1002 | }
|
1003 |
|
1004 | # writes one question score (or a particular text if given)
|
1005 |
|
1006 | sub write_qscore {
|
1007 | my ( $self, $student, $page, $question, $text, $position ) = @_;
|
1008 |
|
1009 | $position = $self->{position} if(!$position);
|
1010 |
|
1011 | return if ( $position eq 'none' );
|
1012 |
|
1013 | $text = $self->qtext( $student, $question ) if ( !defined($text) );
|
1014 |
|
1015 | # no score to write for indicative questions
|
1016 | my $p_strategy = $self->{scoring}->unalias( $student->[0] );
|
1017 | if ( $self->{scoring}->indicative( $p_strategy, $question ) ) {
|
1018 | debug "Indicative question: no score to write";
|
1019 | return;
|
1020 | }
|
1021 |
|
1022 | # if no coordinates, tries with a score zone...
|
1023 | $position = 'zones' if(!$self->{question}->{$question});
|
1024 |
|
1025 | my $xy = "qtext_position_" . $position;
|
1026 | my $command = $self->$xy( $student, $page, $question );
|
1027 |
|
1028 | if ( ref($command) eq 'ARRAY' ) {
|
1029 | if ( $#$command >= 0 ) {
|
1030 | $self->stext($text);
|
1031 | for my $c (@$command) {
|
1032 | $self->command($c) if ($c);
|
1033 | }
|
1034 | }
|
1035 | } elsif ($command) {
|
1036 | $self->stext($text);
|
1037 | $self->command($command);
|
1038 | }
|
1039 | }
|
1040 |
|
1041 | # writes question scores on one page
|
1042 |
|
1043 | sub page_qscores {
|
1044 | my ( $self, $student, $page ) = @_;
|
1045 |
|
1046 | if ( $self->{position} ne 'none' ) {
|
1047 |
|
1048 | $self->set_color( $self->{text_color} );
|
1049 |
|
1050 | # go through all questions present on the page (recorded while
|
1051 | # drawing symbols)
|
1052 | for my $q (
|
1053 | $self->{layout}->page_question_scores( $student->[0], $page ) )
|
1054 | {
|
1055 | $self->write_qscore( $student, $page, $q );
|
1056 | }
|
1057 |
|
1058 | }
|
1059 | }
|
1060 |
|
1061 | # write question IDs, for external scoring
|
1062 |
|
1063 | sub page_qids {
|
1064 | my ( $self, $student, $page ) = @_;
|
1065 |
|
1066 | if ( $self->{position} ne 'none' ) {
|
1067 |
|
1068 | $self->set_color( $self->{text_color} );
|
1069 |
|
1070 | for my $q ( @{ $self->{qnobox} } ) {
|
1071 | $self->write_qscore( $student, $page, $q->{question}, $q->{name},
|
1072 | 'zones' );
|
1073 | }
|
1074 |
|
1075 | }
|
1076 | }
|
1077 |
|
1078 | # draws the page header (only on the first page)
|
1079 |
|
1080 | sub page_header {
|
1081 | my ( $self, $student ) = @_;
|
1082 |
|
1083 | if ( !$self->{header_drawn} ) {
|
1084 |
|
1085 | $self->needs_names;
|
1086 |
|
1087 | $self->set_color( $self->{text_color} );
|
1088 | $self->command("matrix identity");
|
1089 | $self->stext(
|
1090 | $self->{subst}->substitute( $self->{verdict}, @$student ) );
|
1091 | $self->command(
|
1092 | "stext "
|
1093 | . (
|
1094 | $self->{rtl}
|
1095 | ? $self->{width} -
|
1096 | $self->{dist_margin_globaltext} * $self->{dpi}
|
1097 | : $self->{dist_margin_globaltext} * $self->{dpi}
|
1098 | )
|
1099 | . " "
|
1100 | . ( $self->{dist_margin_globaltext} * $self->{dpi} ) . " "
|
1101 | . ( $self->{rtl} ? "1.0" : "0.0" ) . " 0.0"
|
1102 | );
|
1103 |
|
1104 | $self->{header_drawn} = 1;
|
1105 |
|
1106 | }
|
1107 | }
|
1108 |
|
1109 | # annotate a single page
|
1110 |
|
1111 | sub student_draw_page {
|
1112 | my ( $self, $student, $page ) = @_;
|
1113 |
|
1114 | debug "Processing page $student->[0]:$student->[1] page $page->{page} ...";
|
1115 |
|
1116 | # clears boxes positions data for the page
|
1117 | $self->{question} = {};
|
1118 |
|
1119 | my $draw = $self->page_background( $student, $page );
|
1120 | if ( $draw >= 0 ) {
|
1121 | $self->command("line width $self->{line_width}");
|
1122 | $self->command("font name $self->{font_name}");
|
1123 | if(! $self->{header_only} ) {
|
1124 | $self->page_symbols( $student, $page->{page}, $draw > 0 );
|
1125 | $self->page_qscores( $student, $page->{page} );
|
1126 | }
|
1127 | if ( $self->{anonymous} ) {
|
1128 | $self->page_qids( $student, $page->{page} );
|
1129 | }
|
1130 | $self->command("matrix identity");
|
1131 | $self->page_header($student);
|
1132 | } else {
|
1133 | debug "Nothing to draw for this page";
|
1134 | }
|
1135 | }
|
1136 |
|
1137 | # process a student copy
|
1138 |
|
1139 | sub process_student {
|
1140 | my ( $self, $student ) = @_;
|
1141 |
|
1142 | debug "Processing student $student->[0]:$student->[1]";
|
1143 |
|
1144 | # Computes the filename to use, and check that there is no
|
1145 | # up-to-date version of the annotated answer sheet (if so, simply
|
1146 | # keep or rename the file).
|
1147 |
|
1148 | if ( !$self->{single_output} ) {
|
1149 | my ( $f, $f_ok ) = $self->pdf_output_filename($student);
|
1150 | debug "Directory " . show_utf8( $self->{pdf_dir} );
|
1151 | debug "Dest file " . show_utf8($f);
|
1152 | debug "Existing " . show_utf8($f_ok);
|
1153 | my $path = $self->{pdf_dir} . "/$f";
|
1154 | if ( $f_ok ne '' ) {
|
1155 |
|
1156 | # we only need to move the file!
|
1157 | debug "The file is up-to-date";
|
1158 | if ( $f ne $f_ok ) {
|
1159 | debug "... but has to be moved: $f_ok --> $f";
|
1160 | my $path_ok = $self->{pdf_dir} . "/$f_ok";
|
1161 | move( $path_ok, $path )
|
1162 | || debug
|
1163 | "ERROR: moving the annotated file in directory $self->{pdf_dir} from $f_ok to $f";
|
1164 | }
|
1165 | return ();
|
1166 | }
|
1167 | $self->command("output $path");
|
1168 | }
|
1169 |
|
1170 | # Go through all the pages for the student.
|
1171 |
|
1172 | $self->{data}->begin_read_transaction('aOST');
|
1173 |
|
1174 | $self->{header_drawn} = 0;
|
1175 | for my $page ( $self->student_pages($student) ) {
|
1176 | $self->student_draw_page( $student, $page );
|
1177 | }
|
1178 |
|
1179 | $self->{data}->end_transaction('aOST');
|
1180 | }
|
1181 |
|
1182 | # All processing
|
1183 |
|
1184 | sub go {
|
1185 | my ($self) = @_;
|
1186 |
|
1187 | my $n = $self->get_students();
|
1188 |
|
1189 | debug "STUDENTS TO PROCESS: $n\n";
|
1190 |
|
1191 | if ( $n > 0 ) {
|
1192 | $self->process_start;
|
1193 |
|
1194 | # Anonymous mode: get list of anonymous IDs for stusents, and
|
1195 | # list of external questions.
|
1196 |
|
1197 | if ( $self->{anonymous} ) {
|
1198 | $self->{layout}->begin_transaction('aIDQ');
|
1199 |
|
1200 | $self->{qnobox} = $self->{layout}->questions_with_no_box();
|
1201 |
|
1202 | $self->{aIDs} = [
|
1203 | sort { $a cmp $b }
|
1204 | map {
|
1205 | $self->{association}
|
1206 | ->anonymized( $_->[0], $_->[1], $self->{anonymous} )
|
1207 | } @{ $self->{students} }
|
1208 | ];
|
1209 |
|
1210 | $self->{layout}->end_transaction('aIDQ');
|
1211 | }
|
1212 |
|
1213 | # With option <single_output>, all annotated answer sheets are
|
1214 | # made in a single PDF file. We open this file.
|
1215 |
|
1216 | $self->command(
|
1217 | "output " . $self->{pdf_dir} . "/" . $self->{single_output} )
|
1218 | if ( $self->{single_output} );
|
1219 |
|
1220 | # Loop over students...
|
1221 |
|
1222 | for my $student ( @{ $self->{students} } ) {
|
1223 | $self->process_student($student);
|
1224 | $self->{avance}->progres( 1 / $n ) if ( $self->{avance} );
|
1225 | }
|
1226 |
|
1227 | # Anonymized sheets: add a spreadsheet with questions that are
|
1228 | # to be graded externally (with no boxes)
|
1229 |
|
1230 | if ( $self->{anonymous} ) {
|
1231 | if ( @{ $self->{qnobox} } ) {
|
1232 | my @empty = ('') x (1+$#{$self->{qnobox}});
|
1233 | my $csv = Text::CSV->new( { binary => 1, auto_diag => 1 } );
|
1234 | open my $fh, ">", $self->{pdf_dir} . "/external.csv"
|
1235 | or die "Unable to write to $self->{pdf_dir}/external.csv: $!";
|
1236 | $csv->say( $fh,
|
1237 | [ "aID", map { $_->{name} } @{ $self->{qnobox} } ] );
|
1238 | for my $id ( @{ $self->{aIDs} } ) {
|
1239 | $csv->say( $fh,
|
1240 | [ $id, @empty ] );
|
1241 | }
|
1242 | close $fh;
|
1243 | }
|
1244 | }
|
1245 | }
|
1246 | }
|
1247 |
|
1248 | # quit!
|
1249 |
|
1250 | sub quit {
|
1251 | my ($self) = @_;
|
1252 |
|
1253 | $self->{process}->ferme_commande if ( $self->{process} );
|
1254 | $self->{avance}->fin() if ( $self->{avance} );
|
1255 | }
|
1256 |
|
1257 | 1;
|