Auf Knopfdruck (Linux-Magazin, März 2011)

Beim Überführen von Print-Artikeln aus Zeitschriften ins PDF-Format hilft ein Perl-Skript, das den Ablauf über den Druckknopf am Scanner automatisiert.

Langjährige Leser wissen, dass die Perlsnapshot-Kolumne seit bald 14 Jahren läuft. Weniger bekannt ist allerdings die Tatsache, dass sich deswegen mehr als 150 Ausgaben des Linux-Magazins in der Wohnung des Autors stapeln und dort etwa zweieinhalb Regalmeter beanspruchen. Die Anmietung zusätzlichen Stauraumes verbietet sich mit einem Blick auf den Mietspiegel von San Francisco. Vor dem Entsorgen in die Altpapiertonne sollten sie zwecks Befriedigung von Nostalgie-Gefühlen noch ins PDF-Format überführt und mit dem Skript aus [2] in einer Datenbank abgespeichert werden.

Gegen das Ermatten

Scanprogramme wie xsane oder das Ubuntu neuerdings beiliegende simple-scan erledigen Einzelscans ohne viel Aufwand. Beim Einlesen von mehreren Zeitschriftenseiten, deren JPG-Bilder anschließend noch zu einem mehrseitigen PDF-Dokument zusammenfließen müssen, ermattet ohne bessere Automatisierung aber auch der fleißigste Scanner-Operator schnell.

Abbildung 1: Neuen Ubuntu-Distros liegt das Programm simple-scan bei.

Das Perl-Skript artscan führt mittels eines Terminal-basierten Menübalkens durch den Scanvorgang und zeigt in einer Listbox in Echtzeit gerade ablaufende Einzelschritte an (Abbildung 2). Als zusätzliche Erleichterung muss der Bediener lediglich den am Scanner befindlichen grünen Knopf drücken, sobald die aktuelle Artikelseite ordnungsgemäß auf dem Scanner positioniert ist (Abbildung 3). Um eine Reihe bereits eingescannter Einzelbilder zu verwerfen, drückt der User die Taste "N" ("new"), worauf das Skript die zwischengespeicherten Scannerbilder verwirft.

Abbildung 2: Das Programm protokolliert die einzelnen Schritte in einer Listbox mit.

Nach dem Scannen der letzten Seite eines Artikels drückt der User die Taste "F" ("finish") auf der Curses-Oberfläche des Skripts. Dieses überführt daraufhin die zwischengespeicherten Einzelseiten mittels des Programms convert aus dem Fundus der ImageMagick-Programmsammlung vom .pnm-Format in JPEG-Bilder.

Schrumpfen mit JPG

Die damit verbundene Kompression reduziert den Speicherbedarf um bis zu 90%. Ein weiterer Aufruf von convert bündelt die JPG-Sammlung dann zu einem mehrseitigen PDF-Dokument und legt es in einem voreingestellten Ausgabeverzeichnis ab. Die Fußzeile im Terminal zeigt den Pfad des Ergebnisdokuments an. Den Scanvorgang löst der User entweder durch Drücken der Taste "S" im Skript aus oder aber durch den am Scanner befindlichen grünen Knopf.

Abbildung 3: Auf Knopfdruck läuft der Scanner an und liest das Titelbild der Oktoberausgabe des Linux-Magazins aus dem Jahr 1996 ein.

Drück mich

Während der Operator am Scanner hantiert und krampfhaft versucht, die Vorlage trotz des Falzes möglichst bündig einzulegen, wäre es äußerst umständlich, auch noch das im Terminal laufende Skript zu bedienen, um den Scanvorgang auszulösen. Scanner wie mein Epson verfügen deshalb über einen grünen Knopf, der über das USB-Interface ein Signal an den steuernden Rechner zurückgibt, der es beliebig interpretieren kann.

Das unter Ubuntu erhältliche Paket scanbuttond [4] enthält einen Dämon, der eventuell angeschlossene Scanner überwacht und jedesmal, wenn ein Scannerknopf aktiviert wurde, das voreingestellte Skript /etc/scanbuttond/buttonpressed.sh aufruft. Fügt man in dieses eine Zeile wie

    kill -USR1 `cat /tmp/pdfs/pid`

ein, sendet es bei jedem Knopfdruck das Unix-Signal USR1 an einen Prozess, der seine PID in der Datei /tmp/pdfs/pid abgelegt hat. Das Skript artscan tut genau dies sofort nach dem Hochfahren, indem es mittels der Funktion blurt() aus dem Fundus des Moduls Sysadm::Install die in Perl als $$ vorliegende PID des aktuellen Prozesses samt einem abschließenden Zeilenumbruch in die pid-Datei schreibt.

Abbildung 4: Der fertig gescannte Artikel im PDF-Format.

Ringelreih'n mit POE

Wie schon in früheren Kolumnen vorgestellt, tanzt auch die Terminal-Oberfläche von artscan im Takt mit dem POE-Framework vom CPAN. Das Modul Curses::UI::POE stellt die Verbindung der POE-Eventschleife mit der Curses-Library her, die ASCII-basierte Grafikelemente auf die Terminaloberfläche zeichnet und auf Tastendrücke des Users reagiert.

Die Implementierung folgt aus Platzgründen nicht den strengen Regeln des Cooperative-Multitasking-Frameworks, in der eine Task niemals eine andere, wie zum Beispiel die graphische Oberfläche, blockieren darf. Da aber der User zum Beispiel während eines laufenden Scanvorgangs eh nicht viel unternehmen kann, als abzuwarten, bis dieser beendet ist, nimmt es das Skript nicht so genau und friert währenddessen einfach die UI ein.

Listing 1: artscan

    001 #!/usr/local/bin/perl -w
    002 use strict;
    003 use local::lib;
    004 use POE;
    005 use POE::Wheel::Run;
    006 use Curses::UI::POE;
    007 use Sysadm::Install qw(:all);
    008 use File::Temp qw(tempfile);
    009 use File::Basename;
    010 
    011 my $PDF_DIR = "/tmp/artscan";
    012 mkd $PDF_DIR unless -d $PDF_DIR;
    013 
    014 my $pidfile = "$PDF_DIR/pid";
    015 blurt "$$\n", $pidfile;
    016 
    017 my @LBOX_LINES  = ();
    018 my $BUSY        = 0;
    019 my $LAST_PDF;
    020 my @IMAGES      = ();
    021 my $HEAP;
    022 
    023 my $CUI = Curses::UI::POE->new(
    024   -color_support => 1, 
    025   inline_states  => {
    026     _start => sub {
    027       $HEAP = $_[HEAP];
    028       $_[KERNEL]->sig( "USR1", 
    029                        "article_scan" );
    030     },
    031     scan_finished => \&scan_finished,
    032     article_scan  => \&article_scan,
    033 });
    034 
    035 my $WIN = $CUI->add("win_id", "Window");
    036 
    037 my $TOP = $WIN->add( qw( top Label 
    038   -y 0 -width -1 -paddingspaces 1 
    039   -fg white -bg blue
    040 ), -text => "artscan v1.0" );
    041 
    042 my $LBOX = $WIN->add(qw( lb Listbox
    043         -padtop 1 -padbottom 1 -border 1),
    044 );
    045 
    046 my $FOOT = $WIN->add(qw( bottom Label
    047   -y -1 -paddingspaces 1
    048   -fg white -bg blue));
    049 
    050 footer_update();
    051 
    052 $CUI->set_binding(sub { exit 0; },   "q");
    053 $CUI->set_binding( \&article_new,    "n");
    054 $CUI->set_binding( \&article_scan,   "s" );
    055 $CUI->set_binding( \&article_finish, "f" );
    056 
    057 $CUI->mainloop;
    058 
    059 ###########################################
    060 sub article_new {
    061 ###########################################
    062   return if $BUSY;
    063   @IMAGES = ();
    064   footer_update();
    065 }
    066 
    067 ###########################################
    068 sub article_finish {
    069 ###########################################
    070   return if $BUSY;
    071   $BUSY = 1;
    072 
    073   $FOOT->text("Converting ...");
    074   $FOOT->draw();
    075 
    076   my @jpg_files = ();
    077 
    078   for my $image ( @IMAGES ) {
    079       my $jpg_file = 
    080         "$PDF_DIR/" . basename( $image );
    081       $jpg_file =~ s/\.pnm$/.jpg/;
    082       push @jpg_files, $jpg_file;
    083       task("convert", $image, $jpg_file);
    084   }
    085 
    086   my $pdf_file = next_pdf_file();
    087 
    088   $FOOT->text("Writing PDF ...");
    089   $FOOT->draw();
    090 
    091   task("convert", @jpg_files, $pdf_file);
    092   unlink @jpg_files;
    093 
    094   $LAST_PDF = $pdf_file;
    095   @IMAGES = ();
    096 
    097   lbox_add("PDF $LAST_PDF ready.");
    098   footer_update();
    099   $BUSY = 0;
    100 }
    101 
    102 ###########################################
    103 sub next_pdf_file {
    104 ###########################################
    105   my $idx = 0;
    106 
    107   my @pdf_files = sort <$PDF_DIR/*.pdf>;
    108 
    109   if( scalar @pdf_files > 0 ) {
    110     ($idx) = ($pdf_files[-1] =~ /(\d+)/);
    111   }
    112 
    113   return "$PDF_DIR/" . 
    114     sprintf("%04d", $idx + 1) . ".pdf";
    115 }
    116 
    117 ###########################################
    118 sub task {
    119 ###########################################
    120   my($command, @args) = @_;
    121 
    122   lbox_add("Running $command" . " @args");
    123   tap($command, @args);
    124 }
    125 
    126 ###########################################
    127 sub article_scan {
    128 ###########################################
    129   return if $BUSY;
    130   $BUSY = 1;
    131 
    132   my($fh, $tempfile) = tempfile(
    133       DIR    => $PDF_DIR,
    134       SUFFIX => ".pnm", UNLINK => 1);
    135 
    136   lbox_add("Scanning $tempfile");
    137 
    138   my $wheel =
    139     POE::Wheel::Run->new(
    140       Program     => "./scan.sh",
    141       ProgramArgs => [$tempfile],
    142       StderrEvent => 'ignore',
    143       CloseEvent  => "scan_finished",
    144   );
    145 
    146   $HEAP->{scanner} = {
    147     wheel => $wheel, file  => $tempfile };
    148 
    149   $FOOT->text("Scanning ... ");
    150   $FOOT->draw();
    151 }
    152 
    153 ###########################################
    154 sub scan_finished {
    155 ###########################################
    156   my($heap) = @_[HEAP, KERNEL];
    157 
    158   push @IMAGES, $heap->{scanner}->{file};
    159   delete $heap->{scanner};
    160   footer_update();
    161   $BUSY = 0;
    162 }
    163 
    164 ###########################################
    165 sub footer_update {
    166 ###########################################
    167   my $text = "[n]ew [s]can [f]inish [q]" .
    168   "uit (" . scalar @IMAGES . " pending)";
    169 
    170   if( defined $LAST_PDF ) {
    171       $text .= " [$LAST_PDF]";
    172   }
    173   $FOOT->text($text);
    174   $FOOT->draw();
    175 }
    176 
    177 ###########################################
    178 sub lbox_add {
    179 ###########################################
    180   my($line) = @_;
    181 
    182   if( scalar @LBOX_LINES >= 
    183       $LBOX->height() - 4) {
    184     shift @LBOX_LINES;
    185   }
    186   push @LBOX_LINES, $line;
    187 
    188   $LBOX->{-values} = [@LBOX_LINES];
    189   $LBOX->{-labels} = { map { $_ => $_ }
    190                      @LBOX_LINES };
    191   $LBOX->draw();
    192 }

Der in Zeile 26 definierte Start-Handler _start speichert den POE-Session-Heap in der globalen Variablen $HEAP, damit auch die per set_binding() definierten Tastendruckhandler ab Zeile 52 auf die Daten der UI-POE-Session zugreifen können. Damit das Programm auf das Unix-Signal USR1 hin den Handler article_scan anspringt, ruft Zeile 28 die Methode sig() des POE-Kernels auf und weist dem Signal den POE-Zustand "article_scan" zu. Dieser setzt in Zeile 32 die ab Zeile 127 definierte Funktion article_scan als Ansprungadresse. Den dritte POE-Zustand, "scan_finished" schließlich springt der Kernel an, falls ein asynchron abgesetzter Scanvorgang später abgeschlossen ist.

Die grafische Oberfläche baut auf einem in Zeile 35 definierten Window-Element auf und besteht aus einer Top-Leiste $TOP, einer Listbox $LBOX und einer Fußzeile $FOOT. Mit add() fügt das Skript die Widgets von oben nach unten jeweils ins Hauptfenster ein. Die Fußzeile kommt wegen dem Parameterpaar y -1 ganz unten im Fenster zu liegen, die Breiteneinstellung -width 1 der Topleiste bewirkt, dass sich die Leiste über die gesamte Breite des offenen Terminalfensters erstreckt.

Auf einen Druck auf die Taste "N" hin ruft POE wegen des Bindings in Zeile 53 die ab Zeile 60 definierte Funktion article_new() auf, die alle eventuell vorhandenen Elemente des globalen Image-Arrays @IMAGES löscht. Allerdings nur, falls die globale Variable $BUSY nicht gesetzt ist, was an verschiedenen Stellen des Programms geschieht, die sicherstellen wollen, dass der User keine Aktionen durch Tastendrücke auslöst.

Gerade laufende Aktivitäten meldet das Skript entweder über $FOOT-text()> in der Fußzeile oder aber mittels der Funktion lbox_add(), die einen Eintrag an die mittige Listbox anhängt und überschüssige Elemente am oberen Rand abschneidet, sodass sich die Illusion einer scrollenden Datei ergibt. Aufgaben wie das konvertieren von Scanner-Rohdaten im .pnm-Format nach JPEG erledigt die ab Zeile 118 definierte Funktion task. Sie reicht die ihr übergebenen Argumente mittels tap aus dem CPAN-Modul Sysadm::Install an die Shell weiter.

Die erzeugten PDF-Dateien numeriert das Skript von 0001.pdf ab durch und findet den nächsten Wert, indem es das PDF-Verzeichnis nach allen bisher angelegten PDF-Dateien durchsucht und die Nummer der letzten um Eins erhöht.

Arbeitspferd scanimage

Den Scanvorgang könnte nun das CPAN-Modul Sane ([5]) steuern, doch dann müsste sich das Skript um allerlei Krimskrams kümmern, wie zum Beispiel das Freigeben der SANE-Schnittstelle beim Programmabbruch, ohne das künftige Scanversuche hüngen. Statt dessen wählt es den einfachen Weg des dem Sane-Paket beiliegenden Programm scanimage, das es über das Shell-Skript scan.sh aufruft. Wie Listing 2 zeigt, setzt es die Auflösung auf 300 dpi, was für normale Zeitschriften ausreichen sollte. Der Parameter --mode bestimmt mit dem Wert "Color" einen Farbscan, der Default-Modus war bei meinem Epson Schwarz-Weiß. Die von scanimage auf STDOUT ausgegebene Bilddatei im Rohdatenformat PNM leitet das Shell-Skript in eine Datei um, deren Name ihm als Parameter vom Perl-Skript überreicht wurde.

Listing 2: scan.sh

    1 scanimage -x 1000 -y 1000 \
    2     --resolution=300 --mode Color >$1

Der Epson-Scanner belichtet ohne die zusätzlichen Parameter x und y allerdings nur einen kleinen Ausschnitt der verfügbaren Fläche. Die im Shell-Skript verwendeten Werte von jeweils 1000 für -x und -y reduziert der Sane-Backend auf die maximal verfügbare Fläche, was beim Epson ziemlich genau der Größe einer Computerzeitschrift entspricht. Für andere Scannertypen oder Print-Erzeugnisse muss der User die verwendeten Parameter bei Bedarf noch anpassen.

Vergängliche Rohdaten

Zum Einsammeln der Scanner-Rohdaten legt das Skript mit dem CPAN-Modul File::Temp und der daraus exportierten Funktion tempfile in Zeile 132 temporäre Dateien an, die wegen der Option UNLINK nach dem Freigeben der letzten darauf verweisenden Referenz automatisch wieder verschwinden.

Den Aufruf des im gleichen Verzeichnis ausführbar liegenden Scan-Skripts scan.sh übernimmt das POE-Rädchen POE::Wheel::Run, das einen Parallelprozess startet, dort das Shell-Kommando mit der temporären Ausgabedatei aufruft und wegen des Parameters CloseEvent nach getaner Arbeit den POE-Zustand scan_finished anspringt. Dies geschieht asynchron, so dass new() in Zeile 139 sofort wieder zurückkehrt. Damit das Rädchen auch nach dem Verlassen der Funktion article_scan ununterbrochen weiterläuft, speichert Zeile 146 die Wheel-Daten im Session-Heap. Zeile 149 schreibt dann noch schnell "Scanning ..." in die Fußzeile, bevor die Funktion article_scan endet und die Kontrolle zurück an den POE-Kernel geht, der weiter folgende Events abarbeiet. Schließt endlich der Scanner den Einlesevorgang ab, aktiviert das Wheel die Funktion scan_finished ab Zeile 154, die die Wheel-Daten aus dem Heap löscht und den Namen der temporären Datei mit den eingefangenen Rohdaten ans Endes des globalen Arrays @IMAGES anfügt.

Installation

Die Ubuntu-Pakete imagemagick, libfile-temp-perl, libpoe-perl, libcurses-ui-perl und libsysadm-install-perl installieren das nötige Rüstzeug, um das Skript zum Laufen zu bringen. Das Mini-Shell-Skript scan.sh landet ausführbar im gleichen Verzeichnis wie das Hauptskript artscan.

Bietet die Distro kein Paket für Curses::UI::POE an, ist dieses manuell mit einer CPAN-Shell zu installieren. Geschieht dies mittels local::lib, sollte das Skript dieses ebenfalls angeben, wie in Zeile 3 in artscan, andernfalls ist es nicht notwendig. Wer direkt mit dem Sane-Backend des verwendeten Scanners herumspielen möchte, dem sei das CPAN-Modul Sane empfohlen, das auf Ubuntu als libsane-perl vorliegt.

Verbesserungen

Der Scanvorgang lässt sich mit einem Scanner mit Einzelblatteinzug noch effizienter gestalten. Ist der Archivar willens, das Heft mit einer dicken Schere oder Schneidemaschine am Falz aufzutrennen, kann der Scanner die Seiten automatisch eine nach der anderen Einziehen. Die Rückseiten folgen in einem zweiten Durchgang, und das Skript kann die Seiten wieder in die richtige Reihenfolge bringen.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2011/03/Perl

[2]

"Papiercontainer", Michael Schilli, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2005/05/Papiercontainer

[3]

"SANE - Scanner Access Now Easy", http://www.sane-project.org/html

[4]

"scanbuttond", A scanner button daemon for Linux, http://scanbuttond.sourceforge.net

[5]

Perl-Modul Sane: http://search.cpan.org/~ratcliffe/Sane-0.03/lib/Sane.pm

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.