Annotate.pm

Frédéric Bréal, 12/17/2021 05:40 pm

Download (36.5 kB)

 
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;