Aus Linux-Magazin 06/2020

Die Syntax des PDF-Tools Pdftk mit Go anpassen

© Konstantin Pelikh, 123RF

Go eignet sich nicht nur für komplexe Server-Programme, sondern macht auch bei einfachen Kommandozeilenwerkzeugen zur Automatisierung des Alltags eine gute Figur. Mike Schilli strukturiert die Signatur eines PDF-Tools um.

Eines meiner Lieblingswerkzeuge auf der Kommandozeile ist das Utility Pdftk, ein Schweizer Taschenmesser zum Zusammenfügen von PDF-Dokumenten. Allerdings hört das Tool beim Aufruf auf eine ungewöhnliche Syntax zur Übergabe der Parameter. Deshalb spendiere ich ihm eine leichter zu merkende Variante in Go. Auf dem Weg dorthin erfährt der geneigte Leser, wie Go Dateien liest und schreibt, einzelnen Zeichen aus Strings extrahiert und manipuliert, externe Programme mit interaktiver Eingabe aufruft sowie Go-Programme für verschiedene Plattformen kreuzkompiliert.

Papierbücher und Hefte schreddere ich grundsätzlich, nachdem ich sie per Scanner digitalisiert habe. Dabei kommt es vor, dass ein Buch in Form von zwei oder mehr PDFs herauskommt, weil der Scanner sich zwischen zwei Stapeln verhaspelt hat und ich den Scan-Vorgang mit einem neuen Dokument fortsetzen musste. Manchmal passt auch der Buchdeckel eines Hardcovers nicht durch den Scanner-Einzug, und Vorder- und Rückseite liegen als einzelne PDF-Dateien von einem Flachbettscanner vor. Pdftk macht es zum Kinderspiel, 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 sofern diese beispielsweise als »book-1.pdf«, »book-2.pdf« und so weiter durchnummeriert sind, reicht sie sie auch in der korrekten Reihenfolge an Pdftk durch. Das Subkommando »cat« weist das Tool an, alle Eingangsdokumente hintereinander zu hängen, und nach dem Schlüsselwort »output« erwartet Pdftk den Namen der Ausgabedatei. So weit, so gut; aber ginge das nicht standardkonformer oder sogar viel einfacher?

Geht doch!

Das heute vorgestellte Go-Programm Pdftki schnappt sich einfach die PDF-Buchteile. Dabei findet es zum Beispiel heraus, dass sie alle mit »book-*« beginnen, und hängt die Teil-PDFs aneinander. Den Namen der Ergebnisdatei ermittelt es als »book.pdf«, den größten gemeinsamen Nenner aller Teildateien. Das alles gelingt mit einem simplen Aufruf:

$ pdftki book-*.pdf

Schön kompakt und leicht zu merken, oder? Manchmal gilt es aber auch, eine Seite auszulassen, weil sie doppelt vorliegt, etwa am Ende von »book-1.pdf« und am Anfang von »book-2.pdf«. Mit Pdftk erledigt man das, indem man beiden Dokumenten einen Großbuchstaben zuweist und in der »cat«-Anweisung des zweiten Dokuments nicht bei Seite 1 beginnt, sondern bei Seite 2 (Listing 1).

Listing 1

Seite auslassen

$ 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 Manpage aufschlagen muss.

Das für diese Ausgabe entwickelte Go-Tool Pdftki hingegen klaubt mit folgender Anweisung zunächst alle oben angegebenen Parameter zusammen:

$ pdftki -e book-*.pdf

Dann startet es wegen der Option »-e« einen Editor, der dem Anwender die Möglichkeit eröffnet, Anpassungen an den Aufrufparametern für Pdftk vorzunehmen. Nach dem Beenden des Editors führt es schließlich die Teildateien zusammen. Schon wieder Zeit gespart!

Eingebaute Hilfe

Listing 2 zeigt das Hauptprogramm, das mit dem Paket »flag« Kommandozeilenoptionen wie das oben erläuterte »-e« interpretiert und mittels der Methode »Args()« die vom Nutzer angegebene Liste der PDF-Dateien extrahiert.

Listing 2

pdftki.go

package main
import (
  "bytes"
  "flag"
  "fmt"
  "log"
  "os/exec"
)
func main() {
  var edit = flag.Bool("e", false,
               "Pop up an editor")
  flag.Parse()
  pdftkArgs := pdftkArgs(flag.Args())
  if *edit {
    editCmd(&pdftkArgs)
  }
  var out bytes.Buffer
  cmd := exec.Command(pdftkArgs[0],
           pdftkArgs[1:]...)
  cmd.Stdout = &out
  cmd.Stderr = &out
  err := cmd.Run()
  if err != nil {
    log.Fatal(err)
  }
  fmt.Printf("OK: [%s]\n", out.String())
}

In der Variablen »edit« liegt nach dem »Parse()«-Aufruf in Zeile 14 ein Pointer auf einer Variablen vom Typ »bool«. Sie führt standardmäßig den Wert »false«, wechselt aber zu »true«, falls der User »-e« angibt. In diesem Fall springt Zeile 18 die Funktion »editCmd()« aus Listing 4 an. Hier kann der Benutzer die in Zeile 15 ermittelten Argumente für den Pdftk-Aufruf in einem Editor modifizieren (Abbildung 1), bevor Zeile 22 zur Ausführung schreitet.

<a href="#artRef-f1">Abbildung 1</a>: Der Aufruf &raquo;pdftki -e&laquo; l&auml;sst den Anwender das Kommando im Editor Vi modifizieren, bevor er es abschickt.

Abbildung 1: Der Aufruf »pdftki -e« lässt den Anwender das Kommando im Editor Vi modifizieren, bevor er es abschickt.

Das praktische Paket »os/exec« aus dem Go-Standardfundus ruft über »Run()« externe Programme mit ihren Argumenten auf und schneidet auf Wunsch deren Standardausgabe und Standardfehlerausgabe mit. Die Zeilen 24 und 25 weisen den jeweiligen Attributen einen Puffer »out« des Typs »Buffer« aus dem Paket »bytes« zu, worauf »exec« die Ausgaben abfängt und im Puffer hinterlegt. Tritt ein Fehler auf, gibt Zeile 29 ihn als Log-Meldung aus. Geht alles glatt, druckt Zeile 32 via »out.String()« die abgefangenen Ausgaben des Kommandos zur Information des Benutzers aus.

Als Dreingabe gibt es eine kleine Hilfe, die dem Anwender beim Aufruf mit »pdftki -h« erläutert, welche Optionen das Programm überhaupt versteht (Listing 3).

Listing 3

Hilfe aufrufen

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

Tastatur durchleiten

Listing 4 kommt immer dann zum Einsatz, wenn der Anwender auf der Kommandozeile die Option »-e« angegeben hat, also das Kommando vor dem Absenden noch editieren möchte.

Listing 4

edit.go

package main
import (
  "io/ioutil"
  "log"
  "os"
  "os/exec"
  "strings"
)
func editCmd(args *[]string) {
  tmp, err := ioutil.TempFile("/tmp", "")
  if err != nil {
    log.Fatal(err)
  }
  defer os.Remove(tmp.Name())
  b := []byte(strings.Join(*args, " "))
  err = ioutil.WriteFile(
          tmp.Name(), b, 0644)
  if err != nil {
    panic(err)
  }
  cmd := exec.Command("vi", tmp.Name())
  cmd.Stdout = os.Stdout
  cmd.Stdin = os.Stdin
  cmd.Stderr = os.Stderr
  err = cmd.Run()
  if err != nil {
    panic(err)
  }
  str, err := ioutil.ReadFile(tmp.Name())
  if err != nil {
    panic(err)
  }
  line :=
    strings.TrimSuffix(string(str), "\n")
  *args = strings.Split(line, " ")
}

Um ein externes Programm wie eine Instanz des Editors Vi aufzurufen, mit dem der Benutzer auch noch interaktiv agiert, muss der Programmierer dem »exec«-Paket noch mitteilen, dass es nicht nur »Stdout« und »Stderr« des externen Programms an die gleichnamigen Kanäle des offenen Terminals durchleitet, sondern auch die Standardeingabe »Stdin«, damit Tastendrücke des Anwenders zum Editor durchkommen. Die entsprechenden File-Deskriptoren des Systems bietet Go im Paket »os« an; die Zeilen 26 bis 28 von Listing 4 verknüpfen die drei mit den gleichnamigen Lötpunkten des »exec«-Pakets.

Damit der Benutzer den Aufruf im Editor modifizieren kann, muss die Funktion »editCmd()« das Pdftk-Kommando samt Argumenten in einer Datei ablegen und den Editor damit aufrufen. Nachdem der Anwender die Änderungen gesichert hat und zurückgekehrt ist, liest »editCmd()« die Datei aus und speichert deren Inhalt im Array-Format zurück in die als Pointer hereingereichte Variable »args«.

Dazu legt »editCmd()« eine temporäre Datei im Verzeichnis »/tmp« an. Die praktische Funktion »TempFile()« aus dem Standard-Paket »io/ioutil« sorgt dafür, dass deren Name nicht mit 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. Das erledigt der »defer«-Aufruf in Zeile 16, der am Ende der Funktion automatisch den Müllmann vorbeischickt.

Lesen und Schreiben

Das Paket »ioutil« umfasst auch die Komfortfunktionen »ReadFile()« und »WriteFile()«. Sie lesen respektive schreiben ein Stück Text, das als »byte«-Array-Slice vorliegen muss (und nicht etwa als String), aus einer beziehungsweise in eine Datei.

Zeile 18 in Listing 4 fügt hierzu zunächst das Kommando und alle Parameter mittels »Join()« durch Leerzeichen getrennt zu einem langen String zusammen. Den wandelt anschließend der Cast-Operator »[]byte()« in ein »byte«-Array-Slice um.

Umgekehrt liest »ReadFile()« in Zeile 34 die modifizierte Datei aus. In Zeile 39 konvertiert das Programm die hervorsprudelnden Bytes in der Variablen »line« mittels »string()« in eine Zeichenkette. Außerdem schneidet es noch den von Vi am Dateiende ungefragt angehängten Zeilenumbruch ab.

Die Funktion »Split()« in Zeile 40 spaltet Programm und Argumente in ein neues Array auf und weist es dem dereferenzierten Pointer auf das Eingabe-Array zu. Die aufrufende Funktion greift fürderhin über den Pointer nicht mehr auf das Original zu, sondern auf die modifizierten Daten.

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

Listing 5

args.go

package main
import "fmt"
func pdftkArgs(files []string) []string {
  args := []string{"pdftk"}
  catArgs := []string{}
  letterChr := int('A')
  for idx, file := range files {
    letter := string(letterChr + idx)
    args = append(args,
      fmt.Sprintf("%s=%s", letter, file))
    catArgs = append(catArgs,
      fmt.Sprintf("%s1-end", letter))
  }
  args = append(args, "cat")
  args = append(args, catArgs...)
  args = append(args,
                "output", outfile(files))
  return args
}

Größte Gemeinsamkeit

Bleibt noch, aus allen Eingangsdateien mithilfe des größten gemeinsamen Nenners den Namen der Ausgabedatei zu ermitteln. Listing 6 zwickt zu diesem Zweck in »outfile()« mittels der Funktion »Ext()« aus dem Paket »path/filepath« die Endung der Datei ab (hoffentlich ».pdf«).

Dann findet es in »longestSubstr()« ab Zeile 23 die längste den Dateinamen gemeinsame Zeichenkette ab String-Anfang und trennt in Zeile 17 noch einen eventuell anhängenden Bindestrich ab. Der Aufruf ab Zeile 19 fügt dem so ermittelten Basisnamen ein »-out« sowie die eingangs abgetrennte Endung ».pdf« hinzu. Somit steht nun der Name für die Ausgangsdatei fest.

Listing 6

outfile.go

package main
import (
  "fmt"
  "path/filepath"
  "strings"
)
func outfile(infiles []string) string {
  if len(infiles) == 0 {
    panic("Cannot have zero infiles")
  }
  ext := filepath.Ext(infiles[0])
  base := longestSubstr(infiles)
  base = strings.TrimSuffix(base, ext)
  base = strings.TrimSuffix(base, "-")
  return fmt.Sprintf(
           "%s-out%s", base, ext)
}
func longestSubstr(all []string) string {
  testIdx := 0
  keepGoing := true
  for keepGoing {
    var c byte
    for _, instring := range all {
      if testIdx >= len(instring) {
        keepGoing = false
        break
      }
      if c == 0 { // uninitialized?
        c = instring[testIdx]
        continue
      }
      if instring[testIdx] != c {
        keepGoing = false
        break
      }
    }
    testIdx++
  }
  if testIdx <= 1 {
    return ""
  }
  return all[0][0 : testIdx-1]
}

Zum Ermitteln des längsten gemeinsamen Teil-Strings ab Anfang implementiert die Funktion »longestSubstr()« ab Zeile 23 einen kleinen finiten Automaten. Dabei iteriert Zeile 30 über die Namen aller Eingangsdateien und legt in der Variablen »c« den aktuell untersuchten Buchstaben des ersten Dateinamens aus der Liste ab. Dazu nutzt sie die in Go üblichen sogenannten Zero-Values, fixe Werte, die Go noch nicht initialisierten Variablen zuweist.

Die »byte«-Variable »c« ist nach ihrer Deklaration in Zeile 28 noch uninitialisiert und führt deshalb laut Go-Manual den Ganzzahlwert »0«. Dies nutzt Zeile 36 aus, um zu prüfen, ob in der inneren For-Schleife ab Zeile 30 die Variable »c« schon auf den aktuell untersuchten Buchstaben des ersten Dateinamens gesetzt wurde. Falls nicht, holt Zeile 37 das nach. Anschließend geht es mit »continue« weiter in die nächste Runde.

Während der folgenden Durchgänge der inneren Schleife prüft Zeile 41, ob der aktuell untersuchte Buchstabe einer weiteren Datei aus der Liste noch mit dem der ersten Datei (und damit mit dem in »c« gespeicherten Wert) übereinstimmt. Beim ersten Fehlschlag setzt Zeile 42 die Variable »keepGoing« auf »false«, Zeile 43 bricht mit »break« aus der inneren Schleife aus. Das führt auch zum Abbruch der äußeren Schleife, die in jeder Runde den Zähler »testIdx« um eins erhöht, um zu sehen, ob die Dateien nicht vielleicht noch einen weiteren Buchstaben gemeinsam haben.

Am Ende der Funktion ist »testIdx« um eins zu hoch, denn an dieser Indexposition unterscheiden sich die Dateinamen bereits. Folglich gibt »longestSubstr()« in Zeile 53 einen Array-Slice zurück, dessen höchster Elementindex um eins vermindert wurde.

Andere Welten

Das Build-Kommando aus der ersten Zeile von Listing 7 erzeugt aus den vier Teilprogrammen das Executable »pdftki« für die aktuelle Plattform. Der anschließende Aufruf »pdftki *.pdf« leitet die gewünschte Aktion ein. Zusätzliche Module braucht das Programm nicht, es kommt mit Gos Standardbibliothek aus.

Listing 7

Pdftki übersetzen

$ go build pdftki.go edit.go args.go outfile.go
$ GOOS=linux GOARCH=i386 go build pdftki.go edit.go args.go outfile.go

Wer das Binary zwar unter Linux einsetzen möchte, das Programm aber auf einem Mac entwickelt, der übersetzt es dort mit dem Kommando aus der zweiten Zeile von Listing 7. Anschließend lässt sich das so entstandene Executable einfach auf einen Linux-Rechner kopieren, wo es anstandslos läuft. Der umgekehrte Weg funktioniert gegebenenfalls analog.

Schnell oder wartbar

Zugegeben: Teile des vorgestellten Go-Programms wären einfacher als Shell-Skripts zu programmieren gewesen. Für viele Routineaufgaben ist so ein Hauruck-Ansatz praktisch und reicht oft schon aus. Mit steigenden Ansprüchen – etwa beim Ermitteln des längsten Teil-Strings aus einem Array — geraten Skripts allerdings schnell unübersichtlich. Hier bietet Go bessere Wartbarkeit, auch wenn der Aufwand anfangs beträchtlich größer ausfällt. ((uba)/jlu)

Online PLUS

Im Screencast unter http://www.linux-magazin.de/videos/ demonstriert Michael Schilli das vorgestellte Programmierbeispiel.

Der Autor

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 mailto:mschilli@perlmeister.com beantwortet er gerne Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben