Mal mal (Linux-Magazin, September 2002)

Für komplexere Abläufe will man ja oft ein kleines Diagramm zeichnen, zum Beispiel um sie auf einer Webseite zu dokumentieren. Professionelle Grafiker machen das normalerweise mit dem Illustrator von Adobe, aber der ist nicht nur teuer sondern auch elend kompliziert. Heute gibt's statt dessen ein CGI-Skript, das aus einer minimalen textuellen Beschreibung eines technischen Ablaufs ein grafisches Diagramm konstruiert. Nicht schön, aber zweckmäßig. Schließlich sind wir Hacker, keine Models.

Was ist eine einfache Beschreibung? Jedes Ablaufdiagramm besteht aus Knoten und Kanten. Knoten beschreiben die Zustände oder Bedingungen, und die Kanten verbinden sie durch Pfeile miteinander. Beginnen wir mit dem einfachsten Beispiel, zwei Knoten mit einer verbindenden Kante:

   1   Erster Knoten
   2   Zweiter Knoten
   1>2 Kante von Eins nach Zwei

Nach der Beschreibung erhält jeder Knoten eine Nummer, unter der wir ihn weiter unten, wenn's ans Verbinden geht, wieder ansprechen können. Zeile 3 legt mit 1>2 fest, dass die Kante mit der Beschriftung "Kante von Eins nach Zwei" von Knoten 1 nach Knoten 2 zeigt. Abbildung 1 zeigt das Ergebnis im Browser.

Abbildung 1: Das CGI-Skript wandelt die textuelle Diagrammbeschreibung aus einem Kommentar, zwei Knoten und einer verbindenden Kante in ein PNG-Bild um und zeigt es an.

Oder ein komplexeres Beispiel: Eine Spiel, das Fragen stellt und bei der ersten falschen Antwort brutal abbricht -- nur wer alle Fragen richtig beantwortet, gewinnt:

    0 Start
    0>1
    1 Frage anzeigen
    1>2
    2 Antwort richtig?
    2>3 Ja
    2>4 Nein
    3 Weitere Fragen?
    3>5 Nein
    3>1 Ja
    5 Gewonnen!
    4 Verloren

Kanten müssen durchaus nicht immer einen Text mit sich führen. Ein einfaches 1>2 zieht einen Pfeil von Knoten 1 nach Knoten 2, ohne einen Text danebenzusetzen. Wichtig ist nur, dass zwischen den Knoten/Kantenbezeichnungen und dem Beschreibungstext mindestens ein Leerzeichen steht.

GraphViz verwendet graphviz

Wer zeichnet den Graphen? Hinter dem Skript steckt das Perlmodul GraphViz vom CPAN, das seinerseits auf einer Installation der graphviz-Distribution ([2]) aufbaut. Der graphviz-Algorithmus versucht herauszufinden, wie sich das Diagramm am geschicktesten darstellen lässt. graphviz trennt den Inhalt eines Diagramms strikt von der Darstellung. Die textuelle Beschreibung definiert nur Knoten und Kanten. Und obwohl sich die erzeugten Diagramme noch etwas anarchisch geben, schreibt vielleicht irgendwann jemand einen Prozessor, der das ganze hochglanzprospekttauglich macht.

Abbildung 2: Ein komplexeres Beispiel: Der Ablauf eines Spiels, das solange Fragen stellt, bis eine falsche Antwort kommt oder die Fragen ausgehen.

Das ganze läuft auf einem Webserver, nimmt die Beschreibungsdaten in einem HTML-Formular im Browser entgegen und produziert auf Knopfdruck ein Bild im PNG-Format, das anschließend im Browser erscheint. Von dort kann man es einfach mit einem Rechtsklick und unter ``Save As'' für eigene Zwecke als Datei speichern. Auch die textuelle Beschreibung taucht wieder auf und lässt schnelle Änderungen zu.

Aus einer textuellen Diagrammbeschreibung tatsächlich ein grafisches Diagramm zu generieren ist gar nicht so einfach. Es gilt, die richtigen Abstände zwischen den Knoten zu finden und sie so zu positionieren, dass sich die Kanten nicht (oder kaum) überschneiden und das ganze optisch ansprechend aussieht.

Zum Glück haben sich die Jungs auf www.vizgraph.org darüber jahrelang Gedanken gemacht und die Implementierung ihres Verfahrens unter die GPL gestellt: ich ziehe den Hut, Respekt! Mit dem schlauen Perl-Modul GraphViz von Leon Brocard integriert sich das ganze leicht in ein CGI-Skript, das interaktiv Diagramme generiert.

vizgraph besteht einer Reihe von ausführbaren Programmen wie dot und neato, die GraphViz einfach aus Perl ansteuert und eine objektorientierte Schnittstelle darauf anbietet.

Listing 1: malmal

    001 #!/usr/bin/perl
    002 ##########################################################
    003 # malmal -- Mike Schilli, 2002 (m@perlmeister.com)
    004 ##########################################################
    005 use warnings;
    006 use strict;
    007 
    008 use GraphViz;
    009 use CGI qw(:all);
    010 use CGI::Carp qw(fatalsToBrowser);
    011 use URI::URL ();
    012 use Text::Wrap;
    013 $Text::Wrap::columns = 10;
    014 $Text::Wrap::huge    = 'overflow'; 
    015 
    016 if(param('d')) {
    017     print header(-type    => 'image/png', 
    018                  -expires => '-1d');
    019     my $img = draw(param('d'));
    020     if($img) {
    021         print $img;
    022     } else {
    023         print draw("1 Error: $@");
    024     }
    025     exit 0;
    026 }
    027 
    028 print header(), start_html(
    029         -title => "Malmal",
    030         -style => { 'code' => default_css(), }),
    031       h1("Malmal");
    032 
    033 if(param('data')) {
    034    my $u = URI::URL->new( url() );
    035    $u->query_form("d" => param('data'));
    036    print img({ src    => $u->as_string(), 
    037                border => 1
    038              });
    039 }
    040 
    041 print start_form(),
    042       submit(-value => 'Zeichnen'), br(),
    043       font({face => 'Helvetica', size => '2'},
    044         textarea(-name  => 'data', 
    045                  -value => (param('data') || 
    046                             default_data()),
    047                  -rows  => 20, -columns => 100,
    048               ),
    049           ),
    050       br(), submit(-value => 'Zeichnen'),
    051       end_form(), end_html();
    052 
    053 ##########################################################
    054 sub draw {
    055 ##########################################################
    056     my ($data) = @_;
    057 
    058     $ENV{PATH} .= ":/pfad/zur/installation";
    059 
    060     my $g = GraphViz->new(
    061                  node => {
    062                    fontname  => 'arial',
    063                    fontsize  => 10,
    064                    shape     => 'box',
    065                    style     => 'filled',
    066                    fillcolor => 'orange',
    067                  },
    068                  edge => {
    069                    fontname  => 'arial',
    070                    fontsize  => 10,
    071                    fontcolor => 'blue',
    072                    color     => 'blue',
    073                    style     => 'bold',
    074                  },
    075                  rankdir => 1,
    076                );
    077 
    078     my @edges = ();
    079     my %nodes = ();
    080 
    081     for (split /\n/, $data) {
    082         s/#.*//;         # Kommentare
    083         next if /^\s*$/; # Leerzeilen
    084         s/^\s*//;        # Einrückungen
    085         s/\r//g;         # \r-Zeichen
    086 
    087         if(/^(\d+)>(\d+)\s*(.*)/) {
    088             my $text = join "\n", wrap('', '', $3);
    089             push @edges, [$1, $2, $text];
    090         } elsif(/^(\d+)\s*(.*)/) {
    091             my $text = join "\n", wrap('', '', $2);
    092             $nodes{$1} = $text;
    093         } else {
    094             $@ = "Syntax error: $_";
    095             return undef;
    096         }
    097     }
    098 
    099     for my $node (keys %nodes) {
    100         $g->add_node("$nodes{$node} (#$node)");
    101     }
    102 
    103     for my $edge (@edges) {
    104         my($from, $to, $text) = @$edge;
    105 
    106         $g->add_edge("$nodes{$from} (#$from)", 
    107                      "$nodes{$to} (#$to)", 
    108                      label => $text);
    109     }
    110 
    111     return $g->as_png();
    112 }
    113 
    114 ##########################################################
    115 sub default_data {
    116 ##########################################################
    117     return <<EOT;
    118 # -- Bitte hier die Beschreibung eingeben
    119 1   Erster Knoten
    120 2   Zweiter Knoten
    121 1>2 Kante von Eins nach Zwei
    122 EOT
    123 }
    124 
    125 ##########################################################
    126 sub default_css {
    127 ##########################################################
    128     return <<EOT;
    129       body {
    130           background:  #FFFFFF;
    131           font-family: Arial;
    132       } 
    133       input {
    134           font-size: 12px;
    135       }
    136       h1 { 
    137           font-family: Arial;
    138           font-size:   16px;
    139           font-weight: bold;
    140           color: #B82831;
    141       }
    142 EOT
    143 }

Implementierung

Die Zeilen 5 und 6 schalten Perls Warnungen und strenge Programmierrichtlinien an -- immer eine gute Idee, wenn man nicht stundenlang nach idiotischen Fehlern suchen will.

Das in Zeile 8 hereingezogene Modul GraphViz tut die Hauptarbeit, mit CGI lesen wir CGI-Eingabeparameter und schreiben schönes HTML. CGI::Carp mit dem fatalsToBrowser-Tag erleichtert das Debuggen von CGI-Skripts, weil der Browser im Fehlerfall die Ursache anzeigt.

URI::URL manipuliert URLs mit Query-Parametern. Es exportiert eine Funktion url(), die mit der gleichnamigen Funktion von CGI kollidiert -- deswegen weisen wir URI::URL mit () explizit an, nichts zu exportieren.

Text::Wrap bricht Text ordentlich an Wortgrenzen um -- schließlich wollen wir nicht ellenlange Kästen im Diagramm, sondern sauber formatierte. Zeile 13 setzt die Zeilenlänge auf 10 Zeichen und Zeile 14 weist Text::Wrap an, nicht umbrechbare Zeilen einfach fortzuführen.

Zeile 16 prüft, ob das CGI-Skript mit dem Parameter d und der Diagrammbeschreibung aufgerufen wurde. In diesem Fall steckt der Browser dahinter, der das Skript über ein <IMG>-Tag aufrief und ein frisch generiertes PNG-Bild erwartet. Hierzu ruft es die ab Zeile 54 definierte Funktion draw() auf und gibt das zurückkommende PNG-Bild im Binary-Format aus. Vorher jedoch wollen wir den HTTP-Header Content-Type: auf image/png setzen, damit der Browser weiß, was da ankommt. Der expire-Parameter ist auf gestern (-1d) gesetzt, damit das Bild nicht im Cache landet und der Browser immer denselben Schmarr'n darstellt, auch wenn sich die Beschreibung geändert hat. Im Fehlerfall liefert draw() den Wert undef zurück und Zeile 23 geniert ein Pseudo-Diagramm, das eine graphische Fehlermeldung zeigt. Andernfalls wäre im Browser allenfalls ein zerstörtes Bild zu sehen. Zeile 25 bricht das Skript ab. Falls es nur das Bild darstellen sollte, ist hier die Arbeit erledigt.

Soll das Skript die Hauptseite mit (eventuell) Image-Tag und HTML-Formular darstellen, geben die Zeilen ab 28 einen normalen text/html-Header aus, und schreiben HTML, um den Titel der Seite, die Überschrift und ein Stylesheet zu schreiben. default_css() ab Zeile 126 liefert hierzu den passenden Stylesheet-Code.

Zeile 33 prüft, ob der CGI-Parameter data gesetzt ist, also ein Formular mit dem gesetzten Diagrammbeschreibungstext abgeschickt wurde. In diesem Fall erzeugt es mit dem Modul URI::URL und dessen query_form()-Methode einen URL, der aus dem gegenwärtigen Skript-URL (gewonnen mit dem aus CGI exportierten url()) und einem angehängten d=XXX besteht. XXX ist hierbei der URL-codierte Diagrammbeschreibungstext. Diesen URL flickt es in einen <IMG>-Tag, der den Browser wiederum später veranlasst, das entsprechende Diagramm vom Server anzufordern und darzustellen. So wird das Skript malmal vom Browser gleich zweimal hintereinander aufgerufen: Einmal explizit und später hinter den Kulissen über den <IMG>-Tag. Da dieser nach der GET-Methode arbeitet, beschränkt sich die Länge der Diagrammbeschreibung je nach Browsertyp auf 1-4 KB.

Ab Zeile 41 druckt malmal das HTML-Formular aus, in das der Benutzer hoffentlich die Diagrammbeschreibung eintippt. Falls nichts im CGI-Parameter data vorliegt, belegt die Funktion default_data() (ab Zeile 115 definiert) das Beschreibungsfeld mit den in Abbildung 1 gezeigten Testbeispieldaten vor. Zeile 44 definiert das Eingabefeld und Zeile 50 den Submit-Knopf.

Die Funktion draw() ab Zeile 54 tut die eigentliche Arbeit, nimmt die Beschreibung als $text entgegen und liefert ein PNG-Bild im Binärformat zurück. Alternativ könnte es auch JPG, GIF, PostScript, BMP, SVG oder vieles anders mehr sein -- GraphViz ist vielseitig. Zeile 58 fügt dem Pfad in der Umgebungsvariablen PATH das Installationsverzeichnis der graphviz-Software ([2]) hinzu und erlaubt so dem Modul GraphViz, die notwendigen Programme im System zu finden und aufzurufen.

Der Konstruktor des GraphViz-Objekts ab Zeile 60 nimmt drei Parameter mit Optionen entgegen: node definiert einen Optionenhash für alle Knoten im Diagramm und edge für alle Kanten. rankdir legt mit einem wahren Wert fest, dass das Diagramm von links nach rechts fortschreitet, voreingestellt liefe es von oben nach unten.

Neben Farben, Knotenform und Strichstärke legen die Optionen auch die verwendeten Fonts und deren Größe fest. Während Courier immer geht, sieht's halt doch besser aus, wenn man kommerzielle Fonts nimmt, die aber nicht automatisch von Linux aus erhältlich sind. Der Abschnitt Installation zeigt einen Trick, wie man Profi-Fonts einbindet.

Im Beschreibungstext wollen wir durchaus auch zulassen, dass jemand eine Kante zwischen Knoten A und B definiert, ohne dass A oder B vorher definiert wurden. Deswegen sammelt malmal zunächst alle Daten ein und gibt sie dann richtig sortiert an graphviz weiter. @edges ist hierbei ein Array der Kanten und %nodes ein Hash, der jeder Knotennummer den entsprechenden Knotentext zuordnet.

Ab Zeile 81 iteriert die for-Schleife durch den vom Browser gesandten Beschreibungstext. Die Zeilen 82-85 überspringen Kommentare (eingeleitet mit #) und Leerzeilen, eliminieren Einrückungen und werfen die \r-Zeichen raus, die der Browser hineingewurschtelt hat.

Zeile 87 findet Kantenbeschreibungen im Text (z.B. "1>2 Text"), extrahiert Startknoten, Endknoten und Beschreibungstext. Die wrap()-Funktion aus dem Text::Wrap-Modul bricht ihn in Zeilen mit nicht mehr als 10 Buchstaben um und join() fügt sie zu einem mehrzeiligen String in $text zusammen. Zeile 89 legt alle drei Werte in einen Array und schiebt eine Referenz darauf ans Ende von @edges.

Zeile 90 erkennt Knotendefinitionen. Anschließend wird der zugehörige Beschreibungstext formatiert und der eventuell mehrzeilige String dann unter dem Schlüssel der Knotennummer im Hash %nodes abgelegt.

Falls Zeile 94 zum Einsatz kommt, liegt ein Syntaxfehler in der Beschreibung vor, da es sich offenbar weder um eine Knoten- noch um eine Kantenbeschreibung handelt. draw() gibt in diesem Fall undef zurück, was das Hauptprogramm dazu veranlasst, ein Diagramm mit einer Fehlermeldung auszugeben.

Die for-Schleife in Zeile 99 iteriert über alle Knoten und fügt sie der Reihe nach mit der add_node()-Methode in das GraphViz-Objekt ein.

Ab Zeile 103 kommen die Kanten dran. Zeile 104 entpackt die Elemente aus dem Unter-Array. Die add_edge()-Methode erwartet den Text des Startknotens, den des Endknotens und der Parameterwert label gibt die Kantenbeschriftung an.

Dann ist's soweit -- Zeile 111 ruft die as_png()-Methode des GraphViz-Objekts auf, das den Graph als PNG-Bild generiert und dessen Binärdaten als Skalar zurückliefert.

Installation

Auf [2] gibt's die graphviz-Source-Distribution kostenlos zum Abholen. Das übliche

    tar zxfv graphviz-1.8.5.tar.gz
    cd graphviz-1.8.5
    ./configure --prefix=/pfad/zur/installation
    make install

installiert die Programme dot, neato und Konsorten in /pfad/zur/installation. Falls letzerer nicht in der Umgebungsvariablen PATH enthalten ist, muss Zeile 58 in malmal angepasst werden.

Das GraphViz-Modul gibt's vom CPAN:

    perl -MCPAN -e'install GraphViz'

Dann noch schnell das Skript malmal von [1] herunterladen, ins cgi-bin-Verzeichnis verlagert, ausführbar gemacht und den Browser auf http://localhost/cgi-bin/malmal gestellt -- fertig!

Kommerzielle Fonts

Linux kommt aus der Schachtel heraus ja nicht mit kommerziellen Truetype-Fonts daher. Wer schon irgendann mal ein Windows gekauft hat (auch über die Microsoft-Steuer auf PCs) und dieses vielleicht sogar auf demselben Rechner als Double-Boot-Installation betreibt, kann aber die Windows-Fonts verwenden! Man findet sie in /dos/windows/fonts wenn /dos auf die Windows-Platte zeigt, weil es z.B. auf /dev/hdb1 (oder wo immer Windows installiert ist) gemountet ist.

Dann braucht man nur noch xfstt herunterzuladen ([3]), zu übersetzen und zu installieren:

    tar zxfv xfstt-1.1.tar.gz
    cd xfstt-1.1
    make install

Tritt beim Übersetzen ein Fehler auf, liegt's wahrscheinlich daran, dass in den Zeilen 348 und 357 von TTFont.cpp vor die beiden malloc()-Befehle ein (char *) muss. So ist es richtig:

    convbuf = (char *) malloc(sizeof(char) * 256);

Das Installationskommando versucht auch, einen Link von /usr/share/fonts/truetype/winfonts auf /DOS/windows/fonts anzulegen. Das muss an die örtlichen Gegebenheiten angepasst werden. Irgendwo unter dem truetype-Verzeichnis wünscht xfstt jedenfalls die kommerziellen Font-*.ttf-Dateien zu finden.

Dann noch schnell

    echo "Incorporating True Type Fonts ..."
    /usr/X11R6/bin/xfstt --sync
    /usr/X11R6/bin/xfstt &
    /usr/X11R6/bin/xset fp+ unix/:7101

in /etc/rc.d/rc.local geschrieben und schon stehen dem X-Windows-System nach jedem Reboot kommerzielle Fonts zur Verfügung. Auch Gimp und seine Freunde nehmen sie dankbar an.

In eigener Sache

Es ist kaum zu glauben -- der Perl-Snapshot wird nächsten Monat fünf Jahre alt! 60 Artikel! Ich möchte mich ganz herzlich für die zahlreichen Rückmeldungen und Anregungen bedanken -- danke, liebe Snapshot-Fans, nur wegen euch schreibe ich! Bis nächsten Monat!

Infos

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

[2]
Die Homepage des GraphViz-Projekts: http://www.graphviz.org mit der neuesten Distribution unter /pub/graphviz/ARCHIVE/graphviz-1.8.5.tar.gz

[3]
Den Truetype-Fontmanager xfstt-1.1.tar.gz gibt's auf ftp://www.ibiblio.org/pub/Linux/X11/fonts

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.