Nach einem Umzug musste ich kürzlich mehrere Stellen (Versicherungen, Bausparkasse) anschreiben und über meine neue Anschrift informieren. Da ich jedes Mal dabei den selben Text verfassen musste, fand ich es langweilig, das Dokument mit Lyx zu erstellen und anschließend für jeden neuen Empfänger zu kopieren. Was wäre, wenn ich beim zweiten oder dritten Brief auch noch einen Fehler entdecken würde, den ich anschließend bei allen anderen Briefen auch noch korrigieren müsste? Warum also nicht gleich ein kleines Perl-Script schreiben, das aus einer Briefvorlage in LaTeX einfach ein paar Platzhalter ersetzt, die Briefe mit pdflatex als PDFs rendert und zu guter Letzt mit lpr an den Drucker schickt?
Anforderungen an die Serienbrieffunktion
Zu Beginn dieses kleinen Projektes habe ich mir überlegt, welche Anforderungen die Serienbrieffunktion erfüllen muss. Zunächst einmal sollten die Briefe in LaTeX geschrieben werden. Nicht nur deshalb, weil LaTeX ein gutes Textsatzprogramm ist, sondern weil LaTeX-Scripte reine Textdateien sind, die man gut mit Programmmen wie Perl bearbeiten kann.
Während der Absender fix im Template steht, sollten die Empfänger-Daten durch ein Platzhalter-System ersetzt werden können. Das bedeutet auch, dass Bestandteile wie Anrede und Betreffzeile durch personalisierte Inhalte ersetzt werden müssen. Die Betreffzeile kann beispielsweise adressatenbezogene Angaben wie eine Mitglieds-
oder Kundennummer enthalten. Im Anrede-Teil eines Briefes kann man schließlich das Geschlecht des Empfängers beachten: “Sehr geehrte Frau Muster” vs. “Sehr geehrter Herr Muster”. Dabei sollte man auch die Möglichkeit in Betracht ziehen, dass der Emfpänger unbekannt ist: “Sehr geehrte Damen und Herren”.
Zudem sollte man auswählen können, an welche Empfänger der Brief geht. Wenn man eine Adressdatenbank mit mehreren Einträgen hat, müssen nicht in jedem Fall alle Personen angeschrieben werden.
Nett wäre es zudem, wenn das Script nicht nur für jeden der angegebenen Empfänger eine LaTeX-Datei erzeugt, sondern gleich auch ein PDF generiert. Auf diese Weise kann man sich die PDFs mit den Anschreiben vorher anschauen und mögliche Fehler rechtzeitig korrigieren.
Praktisch ist es auch, wenn man dem Script per Parameter mitteilen kann, dass die erzeugten PDFs ausgedruckt werden sollen. Auf diese Weise muss man nicht jedes PDF einzeln in einem PDF-Reader öffnen und ausdrucken.
Diese Anforderungen lassen sich sehr einfach in Perl, LaTeX und lpr (CUPS) unter Linux umsetzen. Dennoch ist ein wenig Planung erforderlich.
Planung
Ich benötige also ein Brief-Template, das in LaTeX geschrieben ist und Platzhalter an den entsprechenden Stellen enthält. Die Platzhalter sollen später durch ein Perl-Script mit den personalisierten bzw. mit adressatenspezifischen Angaben ersetzt werden. Da die Standard-LaTeX-Klasse letter im Layout eher nordamerikanischen Gepflogenheiten folgt, nehme ich die scrlttr2-Klasse aus dem Koma-Script-Paket. Diese Klasse beachtet deutsche Standards für den Brieflayout.
Die Adressdaten der Empfänger sollen schließlich in einfache Textdateien gespeichert werden. Dabei soll jeder Empfänger in einer Datei stehen. Das Dateiformat soll ebenso einfach zu verarbeiten sein. Jede Zeile in einer solchen Adressdatei enthält dabei ein durch Gleichheitszeichen separiertes Schlüssel-Wert-Paar, beispielsweise:
Vorname=Peter
Wenn wir später die Adressdaten mit Perl einlesen, können wir die Daten mit der split-Funktion separieren und die entsprechenden Felder in ein Hash einlesen. Über sprechende Hash-Schlüssel wie “Vorname” oder “Nachname” kann man später sehr leicht auf die entsprechenden Felder zugreifen. Die einzige Bedingung ist nur, dass die Adressdaten alle die selben Feldnamen haben. Folgende Felder sind für die Serienbrieffunktion vorgesehen und sollten in einer Adressdatei enthalten sein:
Feldtitel | Inhalt |
---|---|
salutation | Anrede, also entweder “Herr” oder “Frau”. |
fname | Vorname der anzuschreibenden Person |
lname | Nachname der anzuschreibenden Person |
company | Name des Unternehmens |
street | Straße |
city | Stadt |
plz | Postleitzahl |
myref | Standard-Betreffzeileninhalt, also so etwas wie “Unser Zeichen”. |
Der eigentliche Briefinhalt soll auch in einer separaten Datei gespeichert werden, da es sich um einen Inhalt handelt, der relativ oft ausgetauscht werden soll. Im Text sollen freilich Formatierungsangaben für LaTeX erlaubt sein, denn schließlich wird der Inhalt einfach mit dem entsprechenden Platzhalter im Script ersetzt.
Das Perl-Script selbst sollte folgende Anwendungsfälle abdecken:
- LaTeX-Templatedatei einlesen
- Brief-Inhalt einlesen
- Für jeden Empfänger:
- Adressdatei einlesen
- Adressdaten im Template einfügen
- Brief-Inhalt in Template einfügen
- Brief als LaTeX-Datei speichern
- Aus jeder LaTeX-Datei im Ausgangsordner soll ein PDF erzeugt werden.
- Optional: Wenn ein entsprechender Kommandozeilenparameter übergeben
wurde, sollen die erzeugten PDF-Dateien an den Drucker gesendet werden.
Verzeichnisstruktur für die kleine Serienbrief-Anwendung
Damit die ganzen Dateien wie Template, Adressdaten sowie die erzeugten LaTeX- und PDF-Dateien nicht alle in einem Verzeichnis liegen, habe ich mir eine übersichtliche Verzeichnisstruktur überlegt:
Verzeichnis | Inhalt |
---|---|
./ | Basisverzeichnis mit dem Perl-Script serienbrief.pl |
./adressen/ | Verzeichnis, das die Dateien mit den Empfängeradressen enthält. |
./out/ | Ausgangsverzeichnis, das die erzeugten PDFs enthält, die an den Drucker gehen. |
./template/ | Template-Verzeichnis, das das LaTeX-Template mit den Platzhaltern enthält. |
./tex/ | Verzeichnis mit den erzeugten LaTeX-Dateien. |
Brief-Template in LaTeX schreiben
Das Brief-Template ist in LaTeX schnell geschrieben. Ich habe einfach die Dokumentenklasse scrlttr2 aus dem KOMA-Script-Paket genommen und im LaTeX-Code entsprechende Platzhalter für die Empfängerdaten ([%RECIPIENT%]), die Betreffzeile ([%BETREFF%]), die Anrede-Formel ([%OPENING%]) sowie den Briefinhalt ([%CONTENT%]) eingefügt. Außerdem habe ich die Absenderadresse fest in den Code eingefügt, da sich diese Daten nicht so oft ändern werden:
\documentclass[a4paper,parskip]{scrlttr2}
\usepackage[utf8]{inputenc}
\usepackage{ngerman,sans}
\setkomavar{fromname}{Martin Mustermann}
\setkomavar{fromaddress}{Musterstraße 42\\12345 Musterstadt}
\setkomavar{fromphone}{Tel: +49 (0)123 45 67 89 10}
\setkomavar{signature}{Martin Mustermann}
\setkomavar{date}{\today}
\setkomavar{subject}{[%BETREFF%]}
\begin{document}
\begin{letter}{[%RECIPIENT%]}
\opening{[%OPENING%]}
[%CONTENT%]
\closing{Mit freundlichen Grüßen}
\end{letter}
\end{document}
Anschließend habe ich das LaTeX-Template unter dem Dateinamen template.tex im Verzeichnis ./templates gespeichert.
Adressdateien schreiben
Die Adressdateien entsprechen alle dem in der Planungsphase besprochenen Muster. Jede Datei wird dabei im Verzeichnis ./adressen/ gespeichert und hat folgenden Aufbau:
salutation=Frau
fname=Maria
lname=Musterfrau
company=Globale Kundenabzock AG
street=Abzockerstraße 1
city=Abzockerstadt
plz=12345
myref=Meine Kunden-Nummer 00-000-00/00
Datei mit Briefinhalt schreiben
Die Datei mit dem eigentlichen Briefinhalt ist schnell geschrieben. Es handelt sich dabei um eine einfache Textdatei, die auch Textauszeichnungen wie \textbf{} oder \textit{} enthalten darf. Absätze werden dabei wie in LaTeX üblich durch eine einfache Leerzeile markiert. Zu beachten ist ferner,
dass Sonderzeichen wie \ oder %, die in LateX eine bestimmte Bedeutung haben, maskiert werden. Der Briefinhalt beginnt sofort mit dem Inhalt, ohne dass man sich dabei um die Einleitungs- und Schlussformel kümmern muss. Das “Sehr geehrte(r)” sowie das “Mit freundlichen Grüßen” kommt nämlich jeweils aus dem Template-System:
hiermit bestätige ich Ihnen mein Gestriges und übergebe Ihnen mein \textbf{Heutiges}.
Haben Sie viel Spaß damit und belästigen Sie mich nicht wieder, da ich mich andernfalls
gezwungen sehe, eine nasse Zeitung zu zerreißen, wofür ich Sie \textit{persönlich}
zur Verantwortung ziehen werde.
Die Datei wird anschließend im Basisverzeichnis der Serienbrief-Anwendung unter dem zugegebenermaßen wenig einfallsreichen Dateinamen mail gespeichert.
Perl-Script schreiben
Das Perl-Script besteht aus einigen globalen Variablen, die Kommandozeilenparameter enthalten. Anhand der Parameter wird schließlich entschieden, ob eine main-Sub aufgerufen oder ob eine Sub zum Ausdrucken der generierten PDFs ausgeführt wird. Zusätzliche Subs werden von der main-Sub ausgeführt, um bestimmte Anwendungsfälle wie das Einlesen der Template-Datei oder der Adressdaten auszuführen.
Die Idee beim Perl-Script ist, dass es mit einigen Parametern aufgerufen wird. Die Parameter bestehen dabei aus den Namen der Empfänger, an die das Mailing gehen soll. Der Empfängername entspricht dabei auch dem Namen der Datei, in dem die Adressdaten stehen. zusätzlich soll es möglich sein, bestimmte Anweisungen an das Perl-Script zu übergeben. Anweisungen wie beispielsweise print beginnen dabei immer mit mindestens einem und höchstens zwei “-“. Die Parameter werden direkt beim Start des Scriptes in einigen globalen Variablen gespeichert:
my @files;
my @cmd;
In der ersten Variable werden alle Dateinamen aus der Kommandozeile gespeichert. In der zweiten Variable @cmd werden alle Parameter gespeichert, die aus der Kommandozeile kommen. Dazu wird einfach die grep-Funktion auf das Argumenten-Array losgelassen:
@files = grep(!/^-/, @ARGV);
@cmd = grep(/^-/, @ARGV);
grep macht nichts anderes, als jedes Element in @ARGV, auf das der reguläre Ausdruck zutrifft, in ein Array zu schreiben und dieses Array schließlich als Ergebnis zurückzugeben. Anschließend werden die Befehle im @cmd-Array ausgewertet. Falls das Array den Befehl -print enthält, werden die erzeugten PDFs ausgedruckt, andernfalls wird die main-Sub ausgeführt.
Die main-Sub ruft sukzessive alle subs auf, mit denen die Template-Datei sowie die Adressdaten eingelesen und die entsprechenden Mailings erzeugt werden:
sub main {
# Zuerst lesen wir das TeX-Template ein
my $template = &read_template;
# Anschließend das Anschreiben
my $content = &read_mailcontent;
# Für jede Adresse eine personalisierte Mail erzeugen
foreach $address(@files) {
# Adressdatei lesen
my $hash = &read_address($address);
# Lokale Kopie des Templates erzeugen
my $mail = $template;
# Empfänger-Feld als LaTeX-Code holen
my $addr = &get_address_string($hash);
# Empfänger-Platzhalter im Template ersetzen
$mail =~ s/\[%RECIPIENT%\]/${addr}/g;
# Personalisierte Anrede
my $opening = &get_opening($hash);
# Anrede-Feld im Template ersetzen
$mail =~ s/\[%OPENING%\]/${opening}/g;
# Betreff-Zeile erzeugen
my $subject = &get_subject($hash);
# Betreff-Zeile im Template einfügen
$mail =~ s/\[%BETREFF%\]/${subject}/g;
# Anschreiben im Template einfügen
$mail =~ s/\[%CONTENT%]/${content}/g;
print "Writing mail for $address...\n";
# LaTeX-Datei erzeugen
&write_mail($address, $mail);
print "done.\n";
}
# LaTeX-Dateien in PDFs umwandeln
&generate_pdfs;
}
Als erstes zu nennen ist die Funktion read_template, mit der die Template-Datei eingelesen wird:
sub read_template {
open ($fh, '<:encoding(UTF-8)', "./template/template.tex");
$template = '';
while(<$fh>) {
$template .= $_;
}
close($fh);
}
read_template liest einfach die Template-Datei Zeile für Zeile ein und speichert den Inhalt in der lokalen Stringvariable $template. Diese Variable wird explizit an die aufrufende Instanz zurückgegeben. In der main-Sub wird der Inhalt der Template-Datei in der lokalen Variable $template gespeichert.
Anschließend wird mit der sub read_mailcontent die Datei eingelesen, die das Anschreiben enthält:
sub read_mailcontent {
open ($fh, "<:encoding(UTF-8)", "mail");
my $content = '';
while (<$fh>) {
$content .= $_;
}
close($fh);
chomp($content);
return $content;
}
In der main-Sub wird das Anschreiben in der lokalen Variable $content gespeichert.
Anschließend wird über eine foreach-Schleife das Array mit den Adressdateinamen durchiteriert. Für jede Datei werden folgende Schritte durchgeführt:
- Adressdatei einlesen (read_address)
- Lokale Kopie des Templates erzeugen
- Empfänger-Feld als LaTeX-Code erzeugen (get_address_string)
- Empfänger-Platzhalter in der Template-Kopie ersetzen
- Text für personalisierte Anrede erzeugen (get_opening)
- Anrede-Feld im Template ersetzen
- Betreff-Zeile erzeugen (get_subject)
- Betreff-Zeile im Template ersetzen.
- Anschreiben im Template einfügen
- LaTeX-Datei erzeugen (write_mail)
Nach Abschluss der foreach-Schleife werden alle PDFs auf einmal erzeugt, indem die Sub generate_pdfs aufgerufen wird.
Die Sub read_address liest die Adressdaten ein. Da die Adressdatei aus Schlüssel-Wert-Paaren besteht, lässt sie sich einfach verarbeiten. Die Schlüssel-Wert-Paare werden dabei einfach in ein Hash eingelesen:
sub read_address {
my $file = shift;
my $recipient = {};
open ($fh, "<:encoding(UTF-8)", "./adressen/".$file);
while (<$fh>) {
chomp;
if (/^#/) {
next;
} else {
my @line = split(/=/, $_);
$recipient->{$line[0]} = $line[1];
}
}
close($fh);
return $recipient;
}
Der Hash wird anschließend an die main-Sub zurückgegeben und für die Weiterverarbeitung benutzt.
Mit der Sub get_address_string wird der LaTeX-Code mit den Empfänger-Daten erzeugt. Die Sub erhält dazu als Parameter das Hash mit den Adressdaten:
sub get_address_string {
my $recipient = shift;
my $ret = '';
if ($recipient->{company}) {
$ret .= $recipient->{company} . '\\\\'."\n";
}
if ($recipient->{lname}) {
if ($recipient->{fname}) {
$ret .= $recipient->{fname} . " " . $recipient->{lname} . "\\\\\n";
} elsif ($recipient->{salutation}){
$ret .= $recipient->{salutation} . " ";
$ret .= $recipient->{lname} . '\\\\'."\n";
}
}
$ret .= $recipient->{street} . '\\\\[\\parskip]'."\n";
$ret .= $recipient->{plz} . ' ' . $recipient->{city};
return $ret;
}
Die Sub gibt den LaTeX-Code mit der Adresse wieder an die main-Sub zurück. Im folgenden Schritt wird in der main-Sub der Platzhalter in der Template-Kopie mit dem LaTeX-Code ersetzt.
Nun folgt die personalisierte Anrede. Diese wird in der Sub get_opening erzeugt, die ebenfalls das Hash mit den Adressdaten als Parameter erhält:
sub get_opening {
my $recipient = shift;
my $ret = '';
if ($recipient->{salutation}) {
if ($recipient->{salutation} eq 'Herr') {
$ret = "Sehr geehrter Herr " . $recipient->{lname}
} else {
$ret = "Sehr geehrte Frau " . $recipient->{lname}
}
} else {
$ret = 'Sehr geehrte Damen und Herren';
}
$ret .= ","
}
Das Besondere an dem Code ist, dass er eine Art Fallback-Mechanismus enthält. Falls die Adressdatei keine Angaben zur Anrede enthält, wird einfach der Text “Sehr geehrte Damen und Herren” ausgegeben. Die Sub geht schließlich davon aus, dass bei einem gesetzten Feld salutation auch das Feld lname für den Nachnamen gesetzt sein muss. Unter Umständen kann dies zu einer Warnmeldung führen,falls dieses Feld einmal nicht gesetzt sein sollte, da versucht würde, eine nicht gesetzte Variable in einem String zu verketten.
Der Anrede-Text wird schließlich zurück an die main-Sub gegeben. Der Anrede-Platzhalter in der
Template-Kopie wird schließlich mit dem Anrede-Text ersetzt.
Im folgenden Schritt wird der Text für die Betreffzeile mit der Sub get_subject erzeugt. Auch in diesem Fall erhält die Sub das Hash mit den Adressdaten als Parameter, da die Adressdaten empfängerspezifische Inhalte wie beispielsweise eine Kundennummer enthalten kann. Die Sub ist recht einfach aufgebaut und gibt die Betreffzeile zurück an die main-Sub. In der main-Sub wird schließlich der Platzhalter für die Betreffzeile in der Template-Kopie mit dem Text der Betreff-Zeile ersetzt.
An dieser Stelle der main-Sub wird auch der Platzhalter für den Anschreibetext mit den Inhalt des Anschreibens ersetzt.
Zuletzt wird die LaTeX-Datei mit der Sub write_mail geschrieben. Diese Sub erhält zwei Parameter: Den Namen der Adressdatei und den Inhalt des Anschreibens. Die Sub ist wie folgt aufgebaut:
sub write_mail {
my ($filename, $mail) = @_;
open ($fh, ">:encoding(UTF-8)", "./tex/".$filename . ".tex");
print $fh $mail;
close($fh);
}
Im Prinzip öffnet die Sub lediglich eine Datei im Unterverzeichnis ./tex zum Schreiben. Diese Datei erhält dabei den selben Namen wie die Adressdatei zuzüglich der Dateiendung .tex. Anschließend wird der Inhalt des Briefes in die Date geschrieben und die Datei wird geschlossen. An dieser Stelle endet auch die foreach-Schleife.
Am Ende der main-Sub werden schließlich alle PDFs generiert. Dazu wird die Sub generate_pdfs ausgeführt, die alle zuvor erzeugten .tex-Dateien an pdflatex übergibt:
sub generate_pdfs {
my @tex = <./tex/*.tex>;
foreach my $file (@tex) {
system('pdflatex -interaction=nonstopmode -output-directory ./out/ '.$file);
}
}
Damit endet auch die main-Sub.
Zuletzt soll noch die Sub print_mails vorgestellt werden, mit der die PDFs ausgedruckt werden. Diese Sub liest zuerst alle PDF-Dateinamen aus dem Verzeichnis ./out in ein Array und übergibt anschließend jede einzelne an lpr, dem Kommandozeilenprogramm aus dem CUPS-BSD-Paket:
sub print_mails {
my $printer = shift;
my @pdf = <./out/*.pdf>;
foreach my $file (@pdf) {
#printer is: ML-2250
system("lpr -P $printer -o InputSlot=Default -o PageSize=A4 -o Duplex=None $file");
}
}
Der Drucker wird in der Sub explizit über den Parameter -P ausgewählt. Es ist auch möglich, diesen Parameter einfach wegzulassen, damit lpr den Druckauftrag an den im System eingestellten Standarddrucker übergeben kann. Mit dem Parameter hat man jedoch etwas mehr Flexibilität bei der Auswahl des Druckers.
Wie man das System verbessern und ausbauen kann
Ich habe das System in weniger als zwei Stunden fertiggestellt. Dementsprechend eingeschränkt ist freilich auch die Funktionalität. Beispelsweise werden die Serienbriefe stumpf für alle Empfänger erzeugt, für die eine Adressdatei angelegt worden ist. Wünschenswert wäre also ein System, das die Auswahl bestimmter Empfänger erlaubt.
Der Briefinhalt selbst muss in einer Datei stehen, die einen bestimmten Namen
(“mail”) haben muss. Das ist natürlich wenig komfortabel, da für jedem Serienbrief diese Datei überschreiben muss – ohne zu vergessen, die alte Version vorher zu speichern.
Wünschenswert ist es also, die Nutzdaten wie Adressen und Content in einer Datenbank zu speichern. Die Datenbank erlaubt nicht nur, Adressdaten beispielsweise über Relationen zu einer Kategorientabelle kategorisieren, sondern auch, mehrere Mailings mit dem dazugehörigen Betreff, dem Erstellungsdatum sowie weiteren Metadaten zu speichern.
Eine weitere Verbesserung wäre sicherlich auch eine grafische Benutzeroberfläche. Mit der GUI-Anwendung könnte man sowohl die Adressen eingeben und verwalten als auch die Mailings schreiben. Einzig der Aufwand, der für die Entwicklung aller genannten Erweiterungen betrieben werden müss, ist bei Weitem höher als die zwei Stunden, die ich für dieses kleine Serienbrief-System benötigt habe.