Schreibkraft (Linux-Magazin, November 2003)

Mit Perls PostScript-Modulen und einer Adressdatenbank lassen sich massenweise Umschläge für Serienbriefe beschriften.

Mittlerweile geht ja einfach alles mit Linux: Digitale Bilder von der Kamera einlesen, digitale Musik hören, CDs brennen, sogar mein USB-Scanner brummt zufrieden mit xsane. Doch halt, für eine Aufgabe musste ich bislang noch Windows booten: Um einmal im Monat etwa 20 Umschläge für einen Serienbrief mit Absender und Empfängeradressen aus einer Datenbank zu beschriften, nutzte ich bis vor kurzem noch ein Uralt-Programm aus der Windows-Welt.

Schluss damit! Mit Ghostscript wird aus dem heimischen 08/15-Drucker schnell ein zwölfzylindriger PostScript-Bolide. [2] zeigt wie's geht, wenn's nicht sowieso offensichtlich ist. Die bislang unter Windows genutzte Adressdatenbank ließ sich, wie Abbildung 1 zeigt, problemlos im Komma-separierten CSV-Format exportieren.

Abbildung 1: Die Felder der komma-separierten Adressdatei

Was blieb, war, für jeden Briefumschlag eine PostScript-Datei zu generieren und diese dann an den Drucker zu schicken. Kinderleicht mit mit PostScript::File und PostScript::TextBlock vom CPAN, wie ich aus [3] erfuhr.

Malen nach Zahlen

PostScript sieht ja fast aus wie eine Programmiersprache. Die PostScript-Dateien bestehen aus lesbarem ASCII-Text, der die zur Seitengenierung notwendigen Kommandos aneinanderreiht.

PostScript arbeitet mit einem etwas unkonventionellen Koordinatensystem: Der Ursprung ist die linke untere (!) Ecke des Papiers. Die x-Achse führt von dort aus nach rechts, die y-Achse zeigt nach oben, wie man das aus der Schule gewöhnt ist. Als Einheit dient der PostScript-Point, ein Zweiundsiebzigstel Inch, das wiederum etwa 2.54 Zentimeter misst. 100 PostScript-Punkte sind also ungefähr 3.5 Zentimeter.

Um zum Beispiel den Text Max Schuster ins Adressfeld des Briefumschlags zu platzieren, sind folgende Kommandos notwendig:

    0 setgray 401.95 156 moveto
    /Helvetica-iso findfont
    18 scalefont setfont
    (Max Schuster) show

Hierbei bewegen wir uns zunächst fast 402 Points (etwa 14,2 cm) von der linken unteren Ecke des Umschlags nach rechts und dann 156 Points (etwa 5,5 cm) nach oben, um dann dort die angegebene Buchstabenkette im angegeben Font (Helvetica-iso) in der gesetzten Größe 18 von links nach rechts aufs Papier zu setzen.

Etwas vereinfacht wird dieses Kauderwelsch durch die vom CPAN erhältlichen Module PostScript::File und PostScript::TextBlock. PostScript::File übernimmt die Aufgabe, den PostScript-Header, beginnend mit

    %!PS-Adobe-3.0

zu schreiben und sich um die Seitenorientierung, die Randmaße und die Seitenfolge zu kümmern. PostScript::TextBlock nimmt mehrzeilige Strings entgegen und fängt an einer angegebenen Koordinate an zu schreiben.

Allerdings verlangen diese Module, dass man stundenlang herumtüftelt, damit das Layout einigermaßen an der richtigen Stelle landet. Für die Briefumschläge soll folgendes Layout gelten:

Abbildung 2: Egal ob der Absender einen kurzen oder ...

Abbildung 3: einen langen Namen hat: Der seitliche Abstand bleibt konstant.

Das Skript in Listing envelope definiert einen konstanten Absender in $SENDER (Zeile 14), durchläuft eine Adressdatei und gibt zu jeder gefundenen Adresse einen Umschlag nach Abbildung 2 oder 3 auf dem Drucker aus.

Zeile 13 definiert in $ADDR_CSV den Namen der Adressdatei, die ein Format nach Abbildung 1 aufweisen sollte. Das Kommando, um eine PostScript-Datei durch den Drucker zu jagen, definiert $PRINT_CMD in Zeile 17. Wer das Skript nur im Trockenlauf ausprobieren möchte, ohne gleich Tonnen von Papier zu produzieren, ersetzt "lpr" einfach durch "ghostview", dann erscheinen die Kuverts auf dem Bildschirm.

Zeile 19 öffnet die Adressdatei und der while-Block ab Zeile 22 iteriert durch die Einträge, die jeweils mittels regulärer Ausdrücke extrahiert werden. Statt dessen wäre auch der Einsatz des CPAN-Moduls Text::CSV_XS denkbar gewesen, aber nachdem die Adresseinträge denkbar simpel und ohne Komplikationen wie wörtliche Anführungszeichen oder eingebettete Kommas auskommen, wollen wir's mal nicht übertreiben.

Handarbeit statt Modul-Power

Zeile 23 interpretiert alle mit # (und wahlweise führendem Whitespace) anfangenden Zeilen als Kommentare. Praktisch, falls man nur selektierte Einträge drucken will, dann kommentiert man alle anderen einfach schnell mit # aus. Der split-Befehl in Zeile 24 bricht die Kolonnen an trennenden Kommas auf, map entfernt anschließend alle doppelten Anführungszeichen. s/"//g; gibt nicht den Ergebnisstring zurück, deswegen wird einfach $_; nachgeschaltet.

Ab Zeile 27 entsteht das PostScript::File-Objekt, das mit landscape ins Querformat rotiert, mit reencode => 'ISOLatin1Encoding' auch Umlaute unterstützt und das amerikanische Geschäftsbriefformat ``Envelope-DL'' wählt.

Zeile 36 legt die Adressfeldkolumnen in den Variablen $last, $first, $city und $str ab. Zeile 39 ruft die weiter unten definierte Funktion textbox() auf, die einen mehrzeiligen String, einen Fontnamen, eine Fontgröße und einen Zeilenabstand in PostScript-Points entgegennimmt. Als Fontnamen wählen wir Helvetica-iso, da Helvetica standardmäßig vorhanden ist und mit dem -iso-Zusatz auch Umlaute führt.

textbox() liefert drei Rückgabewerte: Ein PostScript::TextBlock-Objekt sowie die Breite und Höhe des erzeugten Textblocks in PostScript-Points.

Anschließend ruft Zeile 41 die Write()-Methode des PostScript::TextBlock-Objekts auf, um den PostScript-Code zu generieren. Write() nimmt vier Parameter: die Breite und Höhe des Textblocks, sowie den X- und Y-Offset der Startkoordinate. Breite und Höhe übernehmen wir einfach von vorher von der textbox()-Funktion. Als X-Offset (Abstand vom linken Rand) kommen zwei Zentimeter dran, die einfach als cm(2) notieren, denn die weiter unten definierte Funktion cm() rechnet einfach Zentimeter in PostScript-Points um. Der Y-Offset ist schon komplizierter, denn Write() erwartet ihn als Abstand vom unteren Rand, während wir 2cm vom oberen Rand wollen. Aber kein Problem: Methode $ps->get_width() des vorher definierten PostScript::File-Objekts liefert die Höhe des Kuverts und davon zieht Zeile 42 einfach cm(2) ab.

Zu beachten ist, dass PostScript::File trotz landscape-Modus und damit um 90 Grad gedrehter Seite die ursprüngliche Idee von Breite und Höhe beibehält -- für unsere Zwecke liefert jetzt get_width() die Höhe und get_height() die Breite.

Write liefert eine Liste zurück, deren erstes Element der PostScript-Code des Textblocks ist. Zeile 43 jubelt ihn der aktuellen PostScript-Seite unter.

Ähnlich verfahren wir mit dem Empfänger: Zeile 46 fügt Vor- und Nachnamen, Straße und Wohnort zu einem mehrzeiligen String zusammen. Die textbox()-Funktion nimmt diesmal einen leicht größeren Font und Zeilenabstand.

Der X-Offset der linken oberen Ecke der Textbox vom PostScript-Ursprung ist diesmal die Länge des Kuverts ($ps->get_height()) minus der Breite des Textkastens ($bw) minus 2 Zentimeter (cm(2)). Der Y-Offset, also der Abstand der oberen Ecke der Textbox vom unteren Briefrand ist die Höhe des Textkastens plus 2cm ($bh + cm(2)).

Kurzzeitiges Vergnügen

Die Funktion tempfile() aus dem Modul File::Temp legt in Zeile 34 eine temporäre Datei mit einer .ps-Endung an und gibt ein beschreibbares File-Handle sowie den Dateinamen zurück. Die in Zeile 56 aufgerufene output()-Methode soll die PostScript-Daten in dieser Datei ab, erwartet aber deren Namen ohne .ps-Endung, deswegen putzt Zeile 55 diese schnell weg und legt das Ergebnis in $base ab. Nach dem Aufruf des Druckerkommandos in Zeile 59 muss Zeile 63 nur noch die temporäre Datei löschen.

textbox() ab Zeile 68 erzeugt ein neues PostScript::TextBlock-Objekt und ruft dessen addText-Methode auf. Übergeben werden der Fontname, dessen Größe, der Zeilenabstand ($leading) und der zu platzierende Text. Um die Größe des erzeugten Textkastens zu bestimmen, ruft es die weiter unten definierten Funktionen tb_width() und tb_height() (tb für Text Block) auf.

Während tb_height lediglich den Zeilenabstand mit der Anzahl der im Text übergebenen Zeilen multiplizieren muss, gestaltet sich der horizontale Platzverbrauch eines Strings in einem Proportionalfont etwas schwieriger, denn die Buchstaben sind auf dem Papier unterschiedlich breit. Zum Glück gibt es das Modul PostScript::Metrics mit seiner Funktion stringwidth(), die das mittels intern gespeicherter Fonttabellen erledigt. Allerdings kennt sie Helvetica-iso nicht, weswegen Zeile 96 den -iso-Zusatz einfach entfernt. Dann allerdings kennt es keine Umlaute mehr, weshalb Zeile 101 diese einfach durch schlichte Platzhalter in Form des Buchstabens A ersetzt -- das stimmt zwar nicht genau, erfüllt aber den Zweck. Die längste Zeile bestimmt die Breite des Textblocks.

Listing 1: envelope

    001 #!/usr/bin/perl
    002 ###########################################
    003 # envelope - Print paper envelopes
    004 # Mike Schilli, 2003 (m@perlmeister.com)
    005 ###########################################
    006 use warnings;
    007 use strict;
    008 
    009 use PostScript::File;
    010 use PostScript::TextBlock;
    011 use File::Temp qw(tempfile);
    012 
    013 my $ADDR_CSV  = "mailaddr.csv";
    014 my $SENDER    = q{Ansel Absender
    015 Amselweg 9
    016 D-78333 Ansbach};
    017 my $PRINT_CMD = "ghostview";
    018 
    019 open FILE, $ADDR_CSV or  
    020     die "Cannot open $ADDR_CSV";
    021     
    022 while(<FILE>) {
    023   next if /^\s*#/;
    024   my @addr = split /,/, $_;
    025   @addr = map { s/"//g; $_; } @addr;
    026 
    027   my $ps = PostScript::File->new(
    028     landscape   => 1,
    029     reencode    => 'ISOLatin1Encoding',
    030     paper       => "Envelope-DL",
    031   );
    032 
    033   my ($tmp_fh, $tmp_file) = 
    034                  tempfile(SUFFIX => ".ps");
    035 
    036   my($last, $first, $city, $str) = @addr;
    037 
    038     # Sender
    039   my($bw, $bh, $b) = textbox($SENDER, 
    040                   "Helvetica-iso", 10, 12);
    041   my ($code) = $b->Write($bw, $bh, cm(2), 
    042                  $ps->get_width() - cm(2));
    043   $ps->add_to_page($code);
    044 
    045     # Recipient
    046   my $to = "$first $last\n$str\n\n$city\n";
    047   ($bw, $bh, $b) = textbox($to, 
    048                   "Helvetica-iso", 18, 20);
    049   ($code) = $b->Write($bw, $bh, 
    050            $ps->get_height() - $bw - cm(2), 
    051            $bh + cm(2));
    052   $ps->add_to_page($code);
    053 
    054     # Print to temporary file
    055   (my $base = $tmp_file) =~ s/\.ps$//;
    056   $ps->output($base);
    057 
    058   print "Showing $tmp_file\n";
    059 
    060     # Send to printer
    061   system("$PRINT_CMD $tmp_file") and
    062       die "$PRINT_CMD $tmp_file: $!";
    063 
    064     # Delete
    065   unlink "$tmp_file" or
    066       die "Cannot unlink $tmp_file: $!";
    067 }
    068 
    069 ###########################################
    070 sub textbox {
    071 ###########################################
    072     my($text, $font, $size, $leading) = @_;
    073 
    074     my $b = PostScript::TextBlock->new();
    075 
    076     $b->addText(
    077         font    => $font,
    078         text    => $text,
    079         size    => $size,
    080         leading => $leading);
    081 
    082     return(tb_width($text, $font, $size), 
    083            tb_height($text, $leading), 
    084            $b);
    085 }
    086 
    087 ###########################################
    088 sub cm {
    089 ###########################################
    090     return int($_[0]*72/2.54);
    091 }
    092 
    093 ###########################################
    094 sub tb_width {
    095 ###########################################
    096     my($text, $font, $size) = @_;
    097 
    098     $font =~ s/-iso//;
    099 
    100     my $max_width = 0;
    101 
    102     for(split /\n/, $text) {
    103         s/[äÄöÖüÜß]/A/ig;
    104         my $w = 
    105           PostScript::Metrics::stringwidth(
    106                          $_, $font, $size);
    107         $max_width = $w if $w > $max_width;
    108     }
    109 
    110     return $max_width;
    111 }
    112 
    113 ###########################################
    114 sub tb_height {
    115 ###########################################
    116     my($text, $leading) = @_;
    117 
    118     my $lines = 1;
    119     $lines++ for $text =~ /\n/g;
    120 
    121     return $lines*$leading;
    122 }

Maßgeschneidert

Wer andere Umschläge als den angegebenen verwendet, kann das Skript leicht anpassen. Ein DIN-A-6-Umschlag misst beispielsweise etwa 10.47 mal 14.81 Zentimeter, also ist einfach

  my $ps = new PostScript::File(
    landscape   => 1,
    reencode    => 'ISOLatin1Encoding',
    width       => cm(10.47),
    height      => cm(14.81),
  );

einzusetzen.

Zugegeben, diesmal musste ich mit Haken und Ösen programmieren, um die teilweise eigenwillige Implementierung der PostScript::*-Module zu überlisten. Life ain't easy -- aber den Preis zahle ich gerne für die Freiheit, die Linux bietet.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2003/11/Perl

[2]
Hilfe bei der Druckerinstallation unter Linux: http://www.linuxprinting.org

[3]
Shawn Wallace, ``Perl Graphics Programming'', O'Reilly, 2002

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.