Komforthülle (Linux-Magazin, Juni 2020)

Eines meiner Lieblingstools auf der Kommandozeile ist die Utility pdftk, das Schweizer Taschenmesser zum Zusammenfügen von PDF-Dokumenten. Allerdings hört das Tool auf eine ungewöhnliche Syntax zur Übergabe seiner Parameter beim Aufruf, und so verpasste ich ihm in dieser Ausgabe eine leichter zu merkende in Go. Auf dem Weg dorthin erfährt der geneigte Leser, wie Go Dateien einliest und ausschreibt, einzelnen Zeichen aus Textstrings extrahiert und manipuliert, externe Programme mit interaktiver Eingabe aufruft, sowie Go-Programme für verschiedene Plattformen kreuzkompiliert.

Papierbücher und -hefte schnetzele ich grundsätzlich, um sie digital zu scannen. Dabei kommt es vor, dass ein Buch in zwei oder mehr Teilen im PDF-Format vorliegt, weil der Scanner sich zwischen zwei Stapeln verhaspelt hat und ich den Scanvorgang mit einem neuen PDF-Dokument fortsetzen musste. Manchmal passt auch der Buchdeckel eines Hardcovers nicht durch den Scanner-Einzug, und Vorder- und Rückseite liegen als weitere PDF-Dateien von einem Flachbettscanner vor. Mit pdftk ist es nun ein Leichtes, die Teile zu einem Ganzen zusammenzufügen:

    $ pdftk book-*.pdf cat output book.pdf

Dabei schnappt sich die Shell mit *.pdf alle herumliegenden PDF-Dateien, und falls diese zum Beispiel als book-1.pdf, book-2.pdf und so weiter durchnummeriert sind, gibt sie sie auch in der richtigen Reihenfolge an pdftk weiter. Das Subkommando "cat" weist pdftk an, alle Eingangsdokumente hintereinanderzuhängen, und "output" ist das Schlüsselwort, nach dem pdftk den Namen der Ausgabedatei erwartet. So weit so gut, aber ginge das nicht standardkonformer oder sogar massiv einfacher?

Es geht einfacher

Das heute vorgestellte Go-Programm pdftki schnappt sich einfach die PDF-Buchteile, findet zum Beispiel heraus, dass sie alle mit book-* beginnen, hängt die Teil-PDFs aneinander und ermittelt den Namen der Ergebnisdatei als "book.pdf", dem größten gemeinsamen Nenner aller Teildateien, und das alles mit diesem einfach Aufruf:

    $ pdftki book-*.pdf

Schön kompakt und leicht zu merken, oder? Manchmal gilt es aber auch, eine Seite auszulassen, weil sie doppelt vorliegt, zum Beispiel am Ende von book-1.pdf und am Anfang von book-2.pdf, dann kann pdftk das, indem es beiden Dokumenten einen Großbuchstaben zuweist, und in der "cat"-Anweisung des zweiten Dokuments nicht bei Seite 1 sondern Seite 2 anfängt:

    $ pdftk A=book-1.pdf B=book-2.pdf \
    cat A1-end B2-end output book.pdf

Während pdftk also die erste Datei vollständig einbindet ("1-end"), lässt es bei der zweiten die erste Seite aus ("2-end"). Hier zeigt sich die mächtige Seite von pdftk, allerdings zum Preis einer Syntax, wegen der ich regelmäßig die Manualseite aufschlagen muss. Das für diese Ausgabe entwickelte Go-Tool pdftki klaubt hingegen mit

    $ pdftki -e book-*.pdf

alle oben angegebenen Parameter zusammen, öffnet wegen der Option -e einen Editor, der dem User die Chance gibt, etwaige Anpassungen vorzunehmen, und führt nach dem Speichern das Kommando aus. Schon wieder Zeit gespart!

Eingebaute Hilfe

Listing 1 zeigt das Hauptprogramm, das mit dem Paket flag Kommandozeilenoptionen wie das oben erläuterte -e interpretiert und mittels der Methode Args() die vom User angegebene Liste der PDF-Dateien extrahiert. Gleichzeitig baut es praktischerweise eine Hilfe ein, die dem User eintrichtert, welche Optionen das Programm überhaupt versteht:

    $ ./pdftki -h
    Usage of ./pdftki:
      -e        Pop up an editor

In der Variablen edit liegt nach dem Parse()-Aufruf von Zeile 14 ein Pointer auf eine Variable vom Typ bool, die standardmäßig den Wert false führt, aber auf true umschnackelt, falls der User -e angibt. In diesem Fall springt Zeile 18 die Funktion editCmd() aus Listing 2 an, in der der User die bisher in Zeile 15 ermittelten Argumente für den pdftk-Aufruf in einem Editor modifizieren kann (Abbildung 1), bevor Zeile 22 zur Ausführung schreitet.

Listing 1: pdftki.go

    01 package main
    02 
    03 import (
    04   "bytes"
    05   "flag"
    06   "fmt"
    07   "log"
    08   "os/exec"
    09 )
    10 
    11 func main() {
    12   var edit = flag.Bool("e", false,
    13                "Pop up an editor")
    14   flag.Parse()
    15   pdftkArgs := pdftkArgs(flag.Args())
    16 
    17   if *edit {
    18     editCmd(&pdftkArgs)
    19   }
    20 
    21   var out bytes.Buffer
    22   cmd := exec.Command(pdftkArgs[0],
    23            pdftkArgs[1:]...)
    24   cmd.Stdout = &out
    25   cmd.Stderr = &out
    26   err := cmd.Run()
    27 
    28   if err != nil {
    29     log.Fatal(err)
    30   }
    31 
    32   fmt.Printf("OK: [%s]\n", out.String())
    33 }

Das praktische Paket os/exec aus dem Go-Standardfundus ruft mit Run() externe Programme mit ihren Argumenten auf und schneidet auf Wunsch deren Standard-Error und -Output mit. Die Zeilen 24 und 25 weisen den jeweiligen Attributen einen Puffer out vom Type Buffer aus dem Paket bytes zu, worauf exec die Ausgaben abfängt und im Puffer hinterlegt. Falls ein Fehler auftritt, gibt ihn Zeile 29 als Log-Meldung aus, falls alles glatt geht, druckt Zeile 32 mit out.String() die abgefangenen Ausgaben des Kommandos zur Unterhaltung des Users aus.

Abbildung 1: Der Aufruf von pdftk -e lässt den User das Kommando in einem vi-Editor modifizieren, bevor er es abschickt.

Tastatur durchleiten

Listing 2 kommt immer dann zum Einsatz, wenn der User auf der Kommandozeile die Option -e angegeben hat, also das Kommando vor dem Abschicken noch editieren möchte. Um ein externes Programm wie eine Instanz des Editors vi aufzurufen, mit dem der User auch noch interaktiv agiert, wie Listing 2 das macht, muss der Programmierer dem exec-Paket noch mitteilen, dass es nicht nur Standard-Output und -Error des externen Programms an die gleichnamigen Kanäle des offenen Terminals durchleitet, sondern auch die Standardeingabe Stdin, worauf Tastendrücke des Users zum Editor durchkommen. Alle drei dieser File-Deskriptoren des Systems bietet Go im Paket os als Stdout|Stderr|Stdin an, und die Zeilen 26-28 in Listing 2 verknüpfen die drei mit den entsprechenden Lötpunkten des exec-Pakets.

Listing 2: edit.go

    01 package main
    02 
    03 import (
    04   "io/ioutil"
    05   "log"
    06   "os"
    07   "os/exec"
    08   "strings"
    09 )
    10 
    11 func editCmd(args *[]string) {
    12   tmp, err := ioutil.TempFile("/tmp", "")
    13   if err != nil {
    14     log.Fatal(err)
    15   }
    16   defer os.Remove(tmp.Name())
    17 
    18   b := []byte(strings.Join(*args, " "))
    19   err = ioutil.WriteFile(
    20           tmp.Name(), b, 0644)
    21   if err != nil {
    22     panic(err)
    23   }
    24 
    25   cmd := exec.Command("vi", tmp.Name())
    26   cmd.Stdout = os.Stdout
    27   cmd.Stdin = os.Stdin
    28   cmd.Stderr = os.Stderr
    29   err = cmd.Run()
    30   if err != nil {
    31     panic(err)
    32   }
    33 
    34   str, err := ioutil.ReadFile(tmp.Name())
    35   if err != nil {
    36     panic(err)
    37   }
    38   line :=
    39     strings.TrimSuffix(string(str), "\n")
    40   *args = strings.Split(line, " ")
    41 }

Damit der User die Datei im Editor modifizieren kann, muss die Funktion editCmd() in Listing 2 das pdftk-Kommando und dessen Argumente dort ablegen und den Editor damit aufrufen. Nachdem der User die Datei gesichert und zurückkehrt ist, liest edit() die Datei wieder aus, holt die Daten hervor, und speichert sie im Array-Format zurück in die als Pointer hereingereichte Variable args.

Hierzu legt editCmd() eine temporäre Datei im Verzeichnis /tmp an. Die praktische Funktion Tempfile() aus dem Standard-Paket io/ioutil sorgt dafür, dass der Name der Datei nicht mit etwaigen bereits in /tmp vorhandenen Dateien kollidiert, sondern immer eindeutig dem aktuellen Prozess zugeordnet ist. Nach getaner Arbeit muss das Programm die dann obsolete Datei selbst entsorgen, was der defer-Aufruf in Zeile 16 erledigt, der am Ende der Funktion automatisch den Müllmann schickt.

Lesen und Schreiben

Das Paket ioutil bietet auch die Komfort-Funktionen WriteFile() und ReadFile, die ein Stück Text, das als byte-Array-Slice vorliegen muss (und nicht etwa als String) in eine Datei schreibt, beziehungsweise von dort liest. Zeile 18 fügt hierzu zunächst das Kommando und alle Parameter mittels Join() durch Leerzeichen getrennt zu einem langen String zusammen und wandelt diesen anschließend mit dem Cast-Operator []byte(...) in ein byte-Array-Slice um. Umgekehrt liest ReadFile() in Zeile 34 die modifizierte Datei aus, konvertiert die hervorsprudelnden Bytes in der Variablen b mit string() in Zeile 39 in einen String um, und schneidet auch noch den von vi am Dateiende ungefragt angehängten Zeilenumbruch ab. Die Funktion Split() in Zeile 40 spaltet Programm und Argumente in einen neuen Array auf und weist ihn dem dereferenzierten Pointer auf den Eingabe-Array zu: Die aufrufende Funktion greift fürderhin über den Pointer nicht mehr aufs Original sondern auf die modifizierten Daten zu.

Listing 3: args.go

    01 package main
    02 
    03 import "fmt"
    04 
    05 func pdftkArgs(files []string) []string {
    06   args := []string{"pdftk"}
    07   catArgs := []string{}
    08   letterChr := int('A')
    09 
    10   for idx, file := range files {
    11     letter := string(letterChr + idx)
    12     args = append(args,
    13       fmt.Sprintf("%s=%s", letter, file))
    14     catArgs = append(catArgs,
    15       fmt.Sprintf("%s1-end", letter))
    16   }
    17 
    18   args = append(args, "cat")
    19   args = append(args, catArgs...)
    20   args = append(args,
    21                 "output", outfile(files))
    22   return args
    23 }

Die anfangs des Artikels vorgestellte pdftk-Syntax für kompliziertere Fälle, die jeder Eingangsdatei einen Großbuchstaben zuweist und dann jeweils mit A1-end, B1-end und so weiter deren zusammenzufügende Bereiche angibt, baut die Funktion pdftkArgs() aus Listing 3 auf. Dazu iteriert sie in Zeile 10 über alle Eingabedateien, zählt den Index idx bei 0 beginnend jeweils um Eins hoch, erhöht hiermit den in Zeile 8 mit int('A') ermittelten ASCII-Wert, und bekommt damit 'B', 'C', und so weiter.

Größte Gemeinsamkeit

Bleibt noch, aus allen Eingangs-Dateien mithilfe des größten gemeinsamen Nenners den Namen der Ausgabedatei zu ermitteln. Listing 4 zwickt hierzu in der Funktion outfile() mittels der Funktion Ext() des Pakets path/filepath die Endung der Datei ab (hoffentlich ".pdf">. Dann findet sie in longestSubstr() ab Zeile 22 die längste Zeichenkette ab Stringanfang, die die Dateien gemeinsam haben, trennt in Zeile 16 noch einen eventuell anhängenden Bindestrich ab, hängt in Zeile 19 ein "-out" an die Basis an und klatscht die eingangs abgetrennte Endung (.pdf) wieder hintendran. Fertig ist die Ausgangsdatei.

Listing 4: outfile.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "path/filepath"
    06   "strings"
    07 )
    08 
    09 func outfile(infiles []string) string {
    10   if len(infiles) == 0 {
    11     panic("Cannot have zero infiles")
    12   }
    13 
    14   ext := filepath.Ext(infiles[0])
    15   base := longestSubstr(infiles)
    16   base = strings.TrimSuffix(base, ext)
    17   base = strings.TrimSuffix(base, "-")
    18 
    19   return fmt.Sprintf(
    20            "%s-out%s", base, ext)
    21 }
    22 
    23 func longestSubstr(all []string) string {
    24   testIdx := 0
    25   keepGoing := true
    26 
    27   for keepGoing {
    28     var c byte
    29 
    30     for _, instring := range all {
    31       if testIdx >= len(instring) {
    32         keepGoing = false
    33         break
    34       }
    35 
    36       if c == 0 { // uninitialized?
    37         c = instring[testIdx]
    38         continue
    39       }
    40 
    41       if instring[testIdx] != c {
    42         keepGoing = false
    43         break
    44       }
    45 
    46     }
    47     testIdx++
    48   }
    49 
    50   if testIdx <= 1 {
    51     return ""
    52   }
    53   return all[0][0 : testIdx-1]
    54 }

Zur Ermittlung des längsten gemeinsamen Teilstrings ab Anfang implementiert die Funktion longestSubstr() ab Zeile 22 einen kleinen finiten Automaten. Dazu iteriert Zeile 29 über die Namen aller Eingangsdateien und legt in der Variablen c den aktuell untersuchten Buchstaben des ersten Dateinamens in der Liste ab. Dazu nutzt sie die in Go üblichen sogenannten Zero-Values, fixe Werte, die Go noch nicht initialisierten Variablen zuweist. Die Variable c ist nach ihrer Deklaration in Zeile 27 noch uninitialisiert, ist vom Typ byte und führt deshalb laut Manualseite den Integer-Wert 0. Dies nutzt Zeile 34 aus, um zu sehen, ob in der inneren for-Schleife ab Zeile 29 die Variable c schon auf den aktuell untersuchten Buchstaben des ersten Dateinamens gesetzt wurde. Falls nicht, holt Zeile dies nach und leitet mit continue zur nächsten Runde über.

Während der folgenden Durchgänge der inneren Schleife prüft Zeile 39 dann, ob der aktuell untersuchte Buchstabe einer weiteren Datei aus der Liste noch mit dem mit dem der ersten Datei (und damit dem in c gespeicherten) Wert übereinstimmt. Beim ersten Fehlschlag bricht Zeile 40 mit break aus der inneren Schleife und setzt die Variable keepGoing auf false, was auch zum Abbruch der äußeren Schleife führt, die in jeder Runde den Zähler testIdx um Eins erhöht, um zu sehen, ob die Dateien nicht vielleicht noch eine weiteren Buchstaben gemeinsam haben. Am Ende in Zeile 51 ist testIdx um Eins zu hoch, denn an dieser Indexposition unterscheiden sich die Dateinamen bereits. Folglich gibt longestSubstr() in Zeile 51 einen Array-Slice zurück, deren höchster Element-Index um Eins vermindert wurde. Fertig ist der Lack!

Andere Welten

Das Executable pdftki aus allen vier Listings erzeugt das Build-Kommando

    $ go build pdftki.go edit.go args.go outfile.go

für die aktuelle Plattform, ein anschließendes pdftki *.pdf wird die gewünschte Aktion einleiten. Zusätzliche Module braucht das Programm keine, es kommt mit Gos Standardbibliothek aus. Wer das Binary allerdings zum Beispiel auf einem Mac entwickeln, aber auf Linux einsetzen möchte, kann es mit

    $ GOOS=linux GOARCH=i386 go build ...

auf dem Mac für diese Zielplattform vorbereiten, um das entstandene Binary anschließend einfach auf einen Linux-Server zu kopieren, wo es anstandslos laufen wird. Der umgekehrte Weg funktioniert ebenso.

Schnell oder wartbar

Zugegeben, Teile des heutigen Go-Programms wären einfacher als Shell-Skripts zu programmieren gewesen. Für viele Routineaufgaben ist so ein Hauruck-Ansatz praktisch und oft ausreichend, steigen aber die Ansprüche, wie zum Beispiel um den längsten Teilstring aus einem Array zu finden, werden Shell-Skripts schnell unübersichtlich, da bietet Go bessere Wartbarkeit, auch wenn der Aufwand anfangs beträchtlich größer ist.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/05/snapshot/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc