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?
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!
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.
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. |
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.
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.
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.
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.
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.
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!
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.
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.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/05/snapshot/
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc