Schreibe nicht, klebe! (Linux-Magazin, Mai 2011)

OpenOffice hilft mit einer Vielzahl vorkonfigurierter Formate beim Ausdrucken von selbstklebenden Etiketten. Perl speist die dazu erforderlichen Adressdaten ins Dokument ein.

Wer noch altmodisch Glückwunschkarten mit der Hand schreibt und als Brief verschickt, hat vielleicht schon einmal damit geliebäugelt, sich das Adressenschreiben zu sparen und auf selbstklebende Etiketten umzustellen. Die praktischen lasertauglichen Abziehetiketten auf A4-Papier (zum Beispiel Abbildung 1) kosten etwa halben Cent pro Stück (15c pro Blatt) und helfen nicht nur beim antiquierten Snail-Mail-Versand, sondern eignen sich auch zum Beschriften von Netzgeräten und Kabeln. Das heute vorgestellte Perlskript liest kommaseparierte Texte ein und druckt sie zeilenweise auf die Labels. Es druckt also nicht nur Empfängeradressen, wie wär's zum Beispiel damit, mal den Kabelverhau unterm Schreibtisch zu beschriften, damit der gestresste Home-Admin das Router-Netzteil beim nächsten Mal auch sofort findet?

Abbildung 1: Lasertaugliche Etiketten, 30 pro Blatt, 4,200 pro Karton.

Maße vorkonfiguriert

Der OpenOffice Writer kennt bereits von Haus aus die Etikettenmaße vieler Hersteller und erzeugt über das Menü New->Labels (Abbildung 2) entsprechende tabellenartige Dokumente. Der User muss im Dialog in Abbildung 2 dazu lediglich den Hersteller und den Produktcode der verwendeten Etiketten eingeben, schon stimmen die Maße. Die so frisch angelegten Dokumente befüllt der User nur noch mit Textdaten und klickt auf "Drucken" -- viel einfacher, als selbst ein Programm zur Druckerpositionierung zu schreiben. Und da OpenOffice seine Dokumente im offenen ODF-Format ablegt, ist es ein leichtes, die Tabellendaten mittels eines selbstgestrickten Perlskripts aus einer CSV-Datei lesen und ins Dokument einzustreuen.

Abbildung 2: Im Openoffice-Writer öffnet der Labels-Eintrag aus dem File/New-Menü eine reiche Auswahl von Aufkleber-Formaten.

Abbildung 3: Neben dem Aufkleberhersteller erlaubt der Dialog auch die Auswahl der Produktnummer.

ZIP-Archiv .odt

Vor dem automatisierten Einspeichern der Adressen erstellt der User manuell mit OpenOffice einmalig ein Testdokument als Vorlage und tippt einige Teststrings in die ersten vier Felder ein (Abbildung 4). Das anschließend als template.odt abgespeicherte Dokument besteht, wie das unzip-Kommando in Abbildung 5 zeigt, aus einem ZIP-Archiv mit etlichen XML-Dateien, deren interessanteste content.xml mit dem mittels einer XML-Markupsprache ausgezeichneten Textinhalt des Dokuments ist.

Abbildung 4: Der User tippt Beispieltexte in die Tabellenfelder des OpenOffice-Dokuments ein.

Abbildung 5: Ein unzip-Aufruf fördert die XML-Dateien des OpenOffice-Dokuments zutage.

Was nun dort steht und in welchen Markup-Strukturen die vom User vorher in die Tabellenelemente eingegebenen Strings gelandet sind, zeigt der Aufruf des Skripts in Listing 1 mittels oo-dumper template.odt. Es nutzt das CPAN-Modul OpenOffice::OODoc und ruft dessen Konstruktor ooDocument() mit dem Namen der zu untersuchenden Datei auf. Als member legt Zeile 11 "content" fest, zeigt sich also am Dokumenteninhalt interessiert und nicht an ausgelagerten Kopf- oder Fußzeilen, wiederverwertbaren Style-Definitionen oder Meta-Informationen.

XML Erforschen

Die Methode selectElements setzt einen XPath-Query ab, der alle XML-Elemente unterhalb des Tags office:body, also dem eigentlichen Textdokument, zu Tage fördert. Dokumente enthalten nur einen einzigen Body, allerdings besteht OpenOffice::OODoc darauf, dass die linke Seite der Zuweisung in Zeile 14 einen List-Kontext suggeriert, deshalb die einschließenden Klammern um $element. Zurück kommt eine Referenz auf ein Objekt vom Typ OpenOffice::OODoc::Element, das aber aufgrund von Vererbung auch die Methoden des ausführenden XML-Parsers XML::Twig versteht. Dieses schon einmal in einem früheren Snapshot vorgestellte ([3]), etwas eigenwillige XML-Modul stellt die Methode _dump() bereit, die eine textuelle Aufbereitung eines XML-Unterbaums generiert und als String zurückliefert.

Listing 1: oo-dumper

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use OpenOffice::OODoc;
    04 
    05 (my $file) = @ARGV;
    06 
    07 die "usage: $0 file" unless defined $file;
    08 
    09 my $doc = ooDocument( 
    10     file   => $file,
    11     member => "content",
    12 );
    13 
    14 (my $element) = $doc->selectElements(
    15     '//office:body');
    16 
    17 print $element->_dump();

Abbildung 6: Die _dump-Methode zeigt die Verschachtelung des XML-Dokuments.

So zeigt sich in Abbildung 6, dass im Dokument unter dem Tag office:body ein Tag namens office:text hängt, das nach einigen Sequence-Deklarationen wiederum einen Textabsatz vom Typ text:p (Paragraph) enthält. Dieser stellt sich als eine Tabellenzeile mit drei Spalten heraus, für deren Rahmen jeweils Elemente vom Typ draw:frame verantwortlich zeichnen, die wiederum ein draw:text-box-Element mit einem text:p-Element enthalten, in denen sich endlich die eingegebenen Testtexte ("test1", ...) wiederfinden.

Somit fördert ein XPath-Query vom Format

  //office:body/office:text/text:p

alle Tabellenzeilen zutage (die ihrerseits wieder Spaltenrahmen enthalten), während sich die Tabellenelemente (drei pro Zeile) relativ dazu unter

  .../draw:frame/draw:text-box/text:p

finden. Das Skript in Listing 2 bedient sich des ersten XPath-Queries, um erstmal das Dokument um soviele Tabellenzeilen zu erweitern, dass alle auszudruckenden Adressdaten darin Platz finden. Mit dem zweiten Query stöbert es dann durch alle Labels und pfercht jeweils die dafür bestimmten Textdaten hinein.

Zum Öffnen der .odt-Datei bedient es sich wieder des Konstruktors ooDocument() auf die Datei ready.odt, die die Funktion cp aus dem Modul Sysadm::Install in Zeile 21 vorher aus dem Template template.odt erzeugt hat. Die gegenwärtige Version von OpenOffice::OODoc weist einen Bug auf, der sie UTF8-kodierte Daten inkorrekt verarbeiten lässt, falls diese Umlaute enthalten. Die gewählte Einstellung local_encoding =>> ""> behebt das Problem vorläufig, sollte aber eigentlich auf den Wert "utf8" gesetzt sein.

Adressbuch einpflegen

Die Rohdaten legt der User in der Datei address-book.csv (Abbildung 7) ab, von wo sie das Skript label-writer mit dem CPAN-Modul Text::CSV_XS zeilenweise mit getline() ausliest. Die ab Zeile 60 definierte Funktion addresses_scan öffnet dazu die Datei mit dem Pragma :encoding(utf8), damit Perl dort stehende und in UTF-8 kodierte Umlaute auch korrekt einliest und intern in den dafür vorgesehenen Datenstrukturen das UTF-8-Flag setzt.

Die Variable $row zeigt auf ein Array, dessen Elemente die in der CSV-Datei durch Kommata getrennten Zeileneinträge repräsentieren. Um auf der linken Seite des Labels, auf dem der Texteintrag später landet, etwas Platz zu lassen, fügt das Ersetzungskommando in der for-Schleife ab Zeile 77 vor jeder Labelzeile ein Leerzeichen ein. Zeile 82 fügt die Adresszeilen zu einem String mit Zeilenumbrüchen zusammen und schiebt ihn ans Ende des Arrays @addresses, den die Funktion nach getaner Arbeit ans Hauptprogramm zurückreicht.

Abbildung 7: Adressdaten im CSV-Format.

Listing 2: label-writer

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use OpenOffice::OODoc;
    04 use Sysadm::Install qw( :all );
    05 use Text::CSV_XS;
    06 use POSIX qw(ceil);
    07 
    08 my $template        = "template.odt";
    09 my $file            = "ready.odt";
    10 my $addr_book       = "address-book.csv";
    11 my $labels_per_page = 30;
    12 
    13 my @addresses = 
    14    addresses_scan( $addr_book);
    15 
    16 my $addtl_pages = 
    17   ceil( scalar @addresses /
    18        $labels_per_page ) - 1;
    19 
    20   # Put template in place
    21 cp $template, $file;
    22 
    23 my $doc = ooDocument( 
    24  file           => $file,
    25  type           => "content",
    26  local_encoding => "",
    27 );
    28 
    29   # Extend document as necessary
    30 my @rows = $doc->selectElements(
    31     '//office:body/office:text/text:p'
    32 );
    33 
    34 for ( 1 .. $addtl_pages ) {
    35   for my $row ( @rows ) {
    36       $doc->replicateElement( $row, "body" );
    37   }
    38 }
    39 
    40   # All labels, including new ones
    41 my @labels = $doc->selectElements(
    42   '//office:body/office:text/text:p/' .
    43   'draw:frame/draw:text-box/text:p'
    44 );
    45 
    46 my $addr_idx = 0;
    47 
    48 for my $label ( @labels ) {
    49     $doc->setStyle( $label, "P1" );
    50     $doc->setText( $label, 
    51                 $addresses[ $addr_idx ] );
    52     $addr_idx++;
    53     $addr_idx = 0 if 
    54       $addr_idx > $#addresses;
    55 }
    56 
    57 $doc->save();
    58 
    59 ###########################################
    60 sub addresses_scan {
    61 ###########################################
    62   my( $addr_book ) = @_;
    63 
    64   my @addresses = ();
    65 
    66   open( my $fh, "<:encoding(utf8)", 
    67     $addr_book ) or die "$addr_book: $!";
    68 
    69   my $csv = Text::CSV_XS->new ( 
    70    { binary => 1 } ) or die 
    71      "Cannot use CSV: " . 
    72      Text::CSV->error_diag ();
    73 
    74   while( my $row = $csv->getline( $fh ) ) {
    75     unshift @$row, "";
    76 
    77     for ( @$row ) {
    78       s/^/ /;
    79     }
    80 
    81     push @addresses, 
    82          join( "\n", @$row );
    83     }
    84   close $fh;
    85 
    86   return @addresses;
    87 }

Nichts verschwenden

Um nicht unnötig Etiketten zu verschwenden, füllt das Skript eine A4-Seite immer vollständig aus, notfalls durch Wiederholen der Adressen in der CSV-Datei. Andererseits muss Listing 2 bei einer Adressdatenbank, die mehr als 30 Einträge hat, zusätzliche Seiten am Ende des Dokuments einfügen. Auch in diesem Fall füllt es eine am Ende eventuell nicht ganz gefüllte Seite mit wiederholten Daten.

Zeile 16 ermittelt aus der Zahl der Adressen in der CSV-Datei und der vordefinierten Anzahl von Labels pro Seite die notwendige Seitenzahl des Etikettendokuments. Die Funktion ceil() aus dem POSIX-Modul rundet bei gebrochenen Werten auf die nächste ganze Zahl auf. Die Anzahl der zusätzlich gebrauchten Seiten in $addtl_pages ist dann um Eins kleiner, da das Template-Dokument bereits vom User mit einer Seite angelegt wurde.

Alle Tabellenzeilen der Testseite liegen nach dem ersten X-Path-Query in Zeile 30 im Array @rows und für jede zusätzlich zu generierende Seite iteriert die for-Schleife ab Zeile 35 über diese Zeileneinträge, dupliziert sie mit replicateElement() und weist die Funktion mit dem Parameter "body" an, die Dublette am Ende des Dokumentkörpers einzufügen. Die neu erzeugten Zeilen sind exakte Kopien der Zeilen der ersten Seite, enthalten also auch teilweise Elemente mit Testdaten oder sind schlicht leer.

Abbildung 8: Nach dem Skriptlauf enthält die OpenOffice-Datei ready.odt alle eingefügten Adressen.

Der zweite X-Path-Query in Zeile 41 fördert alle Tabellenelemente (drei pro Zeile, inklusive aller Elemente auf neu erzeugten Seiten) des Dokuments hervor und legt sie im Array @labels ab. Die for-Schleife ab Zeile 48 klappert sie ab und weist ihnen den Style "P1" zu. Der Dump in Abbildung 6 zeigt, dass dies daher rührt, dass der User vorher beim Eingeben der Testdaten den Verana-Font eingestellt hat. Der anschließende Aufruf von setText() nimmt den nächsten Datensatz aus der Adressdatei und legt den zugehörigen Textstring im gerade bearbeiteten Tabellenelements ab. Die Schleife zählt die Indexvariable $addr_idx des Adress-Arrays von Null ausgehend stetig hoch und setzt sie auf Null zurück, falls sie das Ende der Adressdatenbank erreicht, um wieder mit der ersten Adresse zu beginnen.

Richtig einlegen

Die abschließend ausgeführte Methode save() auf das Dokument sichert die bislang nur im flüchtigen Speicher ausgeführten Veränderungen in der Zieldatei ready.odt auf der Festplatte. Ruft der User sie mit Openoffice (getestet wurde mit Version 3.2) auf, zeigt sich das Dokument wie in Abbildung 8. Nun gilt es nur noch, eine Seite mit den Selbstklebeetiketten in den Drucker einzulegen und in OpenOffice Writer den Menüeintrag "Print" anzuklicken. Um keine Etiketten zu verschwenden, empfiehlt sich ein Probelauf mit einem Blatt Papier, dessen Aufdruck man anschließend durch ein frisches Etikettenblatt durchscheinen lässt. Ob die Etiketten im Papiereinzug nach oben oder unten zeigen müssen, damit der Drucker sie richtig bedruckt, und in welcher Richtung der Drucker das Blatt einziehen muss, lässt sich am besten dadurch herausfinden, dass man ein Blatt Papier im Einzug an einer Ecke mit Bleistift markiert, die Lage der Markierung auf dem Endergebnis begutachtet und im Kopf komplizierte geometrische Transformationen anstellt.

Abbildung 9: Die fertig gedruckten Etiketten.

Zur Installation sind die Module OpenOffice::OODoc, Sysadm::Install und Text::CSV_XS notwendig, letzteres ist eine geschwindigkeitsoptimierte Version des Oldtimers Text::CSV. Eine CPAN-Shell installiert sie, falls sie in der verwendeten Distribution nicht schon verfügbar sind. Anschließend öffnet der User die Applikation OpenOffice Writer und wählt im Labels-Dialog das verwendete Etikettenformat aus. Der in Zeile 11 von Listing 2 voreingestellte Wert von 30 Labels pro Zeile ist an das verwendete Etikettenformat anzupassen.

Falls etwas nicht stimmt, hilft die Analyse der ODF-Datei mit oo-dumper, um eventuelle Abweichungen vom Format durch passende XPath-Queries zu kompensieren.

Nach dem Ausfüllen einiger Testfelder speichert er die Datei als template.odt ab. Das Skript label-writer sollte dann mit einer ordnungsgemäß in UTF-8 kodierten Adressdatei address-book.csv ohne Ausgabe durchlaufen und die Datei read.odt erzeugen, die der User dann an den Printer schickt.

Offensichtlich bieten sich weitere Anwendungsmöglichkeiten an: Kabelbeschriftungen wie im Rechenzentrum, oder auch Gerätenummern fürs Assett-Management. Vielleicht setze ich mir ja auch mein Buchhalter-Käppi auf, streife die Ärmelschoner über und verpasse jedem Buch meiner Privatbibliothek ein Etikett, das festlegt, in welches Regal es gehört ([5]).

Infos

[1]

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

[2]

"A Simple Way to Do Labels in OpenOffice Writer", Solveig Haugland, http://openoffice.blogs.com/openoffice/2007/06/a-simple-way-to.html

[3]

"Datenfischer", Michael Schilli, Linux-Magazin 08/2005, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2005/08/Datenfischer2

[4]

"The Perl OpenDocument Connector", Jean-Marie Gouarné, The Perl Review, tpr-200604-v3i1.pdf

[5]

"Ab die Post!", Michael Schilli, Linux-Magazin 10/2004, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2004/10/Ab-die-Post

[6]

"Kein Etikettenschwindel", Michael Schilli, Linux-Magazin 2008/10, http://www.linux-magazin.de/Heft-Abo/Ausgaben/2008/10/Kein-Etikettenschwindel

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.