Die Schlechten ins Kröpfchen (Linux-Magazin, November 2021)

Kommandozeilenprogramme in Go sind gut und schön, aber hin und wieder muss doch eine native Desktop-App mit GUI ran, zum Beispiel um vom Handy heruntergeladene Fotos anzuzeigen, und den User die schlechten aussortieren zu lassen. Letztlich bleiben aus dem Wust von hunderten von Fotos auf dem Handy immer nur einige wenige, für die es sich lohnt, sie andersweitig aufzuheben.

Vor drei Jahren hat der Programmier-Snapshot schon einmal ein ganz ähnliches graphisches Tool vorgestellt, das den User schlechte Fotos händisch ausmustern ließ ([3]), und es lief auf dem Electron-Framework und dem damit ferngesteuerten Chrome-Browser auf Node.js-Basis. Nun hat sich in letzter Zeit das Go-GUI-Framework Fyne ([2]) das Ziel gesteckt, Electron Konkurrenz zu machen, und die Welt der Entwicklung von Cross-Plattform-GUIs zu dominieren. In dieser Ausgabe schauen wir mal, wie einfach sich so ein Ausmusterer in Go und Fyne schreiben lässt.

Abbildung 1: Die simple Fyne-App aus Listing 1 zeigt ein Foto an.

In [2] hat das Linux-Magazin letztens einfache Beispiele mit Fyne gezeigt, aber eine echte Applikation erfordert noch etwas zusätzlichen Feinschliff. Listing 1 zeigt den ersten Versuch einer Foto-App, die ein JPG-Bild von der Festplatte liest, und es mitsamt einem Quit-Button in einem Fenster anzeigt. Das Foto stammt von meiner letzten Deutschlandtour 2021, wo ich mich aufgemacht habe, die besten Brezenbäcker Deutschlands zwischen Bremen und Bad Tölz zu aufzuspüren. Abbildung 1 zeigt die App kurz nach dem Aufruf von der Kommandozeile mit einer sagenhaften Breze aus Lenggries am südlichsten Zipfel Deutschlands. Am Layout der App mit dem Foto und dem Ausschaltknopf gäb's nichts zu meckern, außer dass es gut 2 Sekunden dauert, um ein Bild der Handykamera in der Auflösung 4032 x 3024 von der Platte zu laden und im Applikationsfenster anzuzeigen. Völlig undenkbar, mit derart schlappem Handling ein brauchbares Tool zur Bildsortierung zu bauen!

Listing 1: img.go

    01 package main
    02 
    03 import (
    04   "fyne.io/fyne/v2"
    05   "fyne.io/fyne/v2/app"
    06   "fyne.io/fyne/v2/canvas"
    07   "fyne.io/fyne/v2/container"
    08   "fyne.io/fyne/v2/widget"
    09   "os"
    10 )
    11 
    12 func main() {
    13   win := app.New().NewWindow("imgtest")
    14   img :=
    15     canvas.NewImageFromFile("pretz.jpg")
    16   img.SetMinSize(fyne.NewSize(600, 400))
    17 
    18   button := widget.NewButton("Quit",
    19     func() { os.Exit(1) })
    20 
    21   con := container.NewVBox(img, button)
    22   win.SetContent(con)
    23   win.ShowAndRun()
    24 }

Schnell dank Profi-Tricks

Die in dieser Ausgabe vorgestellte blitzschnelle GUI, die die Bilder aus dem aktuellen Verzeichnis nacheinander anzeigt, mittels vi-artiger Steuerung durch "L" bzw. "H" zum nächsten bzw vorherigen Bild flutscht, und mit "d" (für 'delete') das gerade angezeigte Bild ins Mülleimerverzeichnis "old" wirft, sortiert in Rekordzeit die guten ins Töpfchen und die schlechten ins Kröpfchen ([5]). Wem die vi-Tastaturbindung übrigens sauer aufstößt, und statt dessen lieber die Cursortasten bedient, der kann natürlich den Code einfach anpassen!

Während die Trivial-App aus Listing 1 doch recht gemächlich werkelt, arbeitet sich die Foto-App "inuke" aus Listing 2 viel schneller durch die Fotosammlung. Mit einigen Tricks aus der Performance-Kiste zeigt sie das nächste Foto praktisch sofort an, mit einer Verzögerung von weniger als einer gefühlten 1/10-Sekunde, nachdem es der User durch einen Druck auf die "L"-Taste angefordert hat. Hexenwerk? Nichts läge dem Programmier-Snapshot ferner.

Erstens hilft das Zwischenspeichern bereits geladener Fotos, sodass der GUI-Renderer sie nur noch aus dem Cache in den Video-Speicher schieben muss, falls der User wieder danach verlangt. Doch welche Fotos lohnt es sich, vorzuhalten, wenn nicht alle ins RAM passen, weil zum Beispiel ein Verzeichnis 5000 Fotos a 4 MByte enthält und nicht jeder 20GB RAM verpulvern kann? Die Lösung ist ein LRU-(Last Recently Used)-Cache, der eine vorab definierte Maximalzahl an Einträgen aufnimmt, aber bei Überfüllung einfach diejenigen Elemente hinauswirft, deren letztes Zugriffsdatum am weitesten zurückliegt. So überschreiben neu hinzugekommene Einträge einfach uralte, falls der Cache bereits voll ist.

Als zweites Tuning-Mittel hilft das effiziente Verkleinern der Fotos bei der Anzeige, denn kaum ein Monitor zeigt 4032-Pixel breite Bilder vollständig an. Wer die unverkleinerten Fotos der GUI zur Anzeige übergibt, lässt sie mehr Arbeit erledigen als notwendig, und das rächt sich durch schleppende Interaktion mit dem User, der auf jeden Tastendruck hin verzögerungsfrei ein neues Bild sehen will. Die Go-Library nfnt auf Github bietet hocheffiziente Routinen zum Schrumpfen von Bildern an, eine performante App verkleinert die Fotos stets auf das auf den Bildschirm passende, schon bevor sie in den Cache wandern.

Und drittens hilft der App ein Preload-Mechanismus auf die Sprünge. Designbeding zeigt sie Fotos immer in einer bestimmen Reihenfolge an, entweder vorwärts oder rückwärts, je nachdem in welcher Richtung der User gerade unterwegs ist. Also kann sie leicht vorhersagen, welches Foto der User beim nächsten Tastendruck auf dem Schirm erscheinen soll. Lädt nun die App das wahrscheinlich gleich folgende Foto bereits vorab im Hintergrund in den Cache, während der User noch auf das aktuelle Foto starrt, kann das Fyne-Framework das nächste Foto praktisch sofort anzeigen, wenn es der User endlich durch Tastendruck anfordert.

Zapp! Zarapp!

Das Ergebnis ist erstaunlich, mit diesen drei Verbesserungen flutscht die Anzeige des Go-Programms in atemraubenden Tempo und schlägt in Punkto Elan so manche professionelle App. In Abbildung 2 hat inuke gerade ein Bild geladen, das den Autor während seiner Deutschlandtour 2021 als Tourist in Heidelberg zeigt. Das unten anhängende kleine Label-Widget zeigt die in der App nun erlaubten Tastendrücke, mit "H" springt die App zurück zum letzten Bild, mit "L" zur nächsten Aufnahme, mit "D" löscht sie das aktuell Foto und mit "Q" bricht die App das Programm ab. Wie sieht nun die Programmierung des Problems in Go aus?

Abbildung 2: Die mit dem Fyne-Framework generierte App iNuke zeigt ein Foto zur Auswahl.

Listing 2: inuke.go

    001 package main
    002 
    003 import (
    004   "container/list"
    005   "os"
    006 
    007   "fyne.io/fyne/v2"
    008   "fyne.io/fyne/v2/app"
    009   "fyne.io/fyne/v2/canvas"
    010   "fyne.io/fyne/v2/container"
    011   "fyne.io/fyne/v2/storage"
    012   "fyne.io/fyne/v2/widget"
    013   "github.com/hashicorp/golang-lru"
    014   "path/filepath"
    015 )
    016 
    017 var Cache *lru.Cache
    018 
    019 func main() {
    020   win := app.New().NewWindow("iNuke")
    021 
    022   var err error
    023 
    024   Cache, err = lru.New(128)
    025   panicOnErr(err)
    026 
    027   cwd, err := os.Getwd()
    028   panicOnErr(err)
    029 
    030   dir, err := storage.ListerForURI(
    031     storage.NewFileURI(cwd))
    032   panicOnErr(err)
    033 
    034   files, err := dir.List()
    035   panicOnErr(err)
    036 
    037   images := list.New()
    038 
    039   for _, file := range files {
    040     if isImage(file) {
    041       images.PushBack(file)
    042     }
    043   }
    044 
    045   if images.Len() == 0 {
    046     panic("No images found.")
    047   }
    048 
    049   cur := images.Front()
    050 
    051   img := canvas.NewImageFromResource(nil)
    052   img.SetMinSize(
    053     fyne.NewSize(DspWidth, DspHeight))
    054   lbl := widget.NewLabel(
    055     "[H] Left [L] Right [D]elete [Q]uit")
    056   con := container.NewVBox(img, lbl)
    057   win.SetContent(con)
    058 
    059   showURI(win, cur.Value.(fyne.URI))
    060   showImage(img, cur.Value.(fyne.URI))
    061   preloadImage(scrollRight(images,
    062     cur).Value.(fyne.URI))
    063 
    064   win.Canvas().SetOnTypedKey(
    065     func(ev *fyne.KeyEvent) {
    066       key := string(ev.Name)
    067       switch key {
    068       case "L":
    069         cur = scrollRight(images, cur)
    070       case "H":
    071         cur = scrollLeft(images, cur)
    072       case "D":
    073         if images.Len() == 1 {
    074           panic("Not enough images!!")
    075         }
    076         old := cur
    077         cur = scrollRight(images, cur)
    078         toTrash(old.Value.(fyne.URI))
    079         images.Remove(old)
    080       case "Q":
    081         os.Exit(0)
    082       }
    083       showURI(win, cur.Value.(fyne.URI))
    084       showImage(img,
    085         cur.Value.(fyne.URI))
    086       preloadImage(scrollRight(images,
    087         cur).Value.(fyne.URI))
    088     })
    089 
    090   win.ShowAndRun()
    091 }
    092 
    093 func showURI(win fyne.Window, uri fyne.URI) {
    094   win.SetTitle(filepath.Base(uri.String()))
    095 }
    096 
    097 func scrollRight(l *list.List,
    098   e *list.Element) *list.Element {
    099   e = e.Next()
    100   if e == nil {
    101     e = l.Front()
    102   }
    103   return e
    104 }
    105 
    106 func scrollLeft(l *list.List,
    107   e *list.Element) *list.Element {
    108   e = e.Prev()
    109   if e == nil {
    110     e = l.Back()
    111   }
    112   return e
    113 }

Das Hauptprogramm in Listing 2 definiert zuerst in Zeile 16 mit app.New() ein neues GUI-Fenster und pfercht später mit showImage() in den Zeilen 58 und 81 neugeladene Bilder hinein.

Erst ermittelt es in Zeile 23 das aktuelle Verzeichnis, liest alle darin enthaltenen JPG-Fotos ein und speichert die Datei-URLs zu deren Pfaden in einer Storage-Struktur des Fyne-Frameworks. Dies dient in Fyne zur Abstraktion von Dateipfaden, denn nicht alle Betriebssysteme bieten Zugriff auf ein Dateisystem. So kann ein mobiles Telefon zum Beispiel Daten aus der Cloud holen oder aus einer lokalen Datenbank, und dank Fynes Abstraktionslayer verarbeiten die nachfolgenden Funktionen die Daten vollkommen transparent.

Die Tastendrücke des Users fängt SetOnTypedKey() mit einer Callback-Funktion ab Zeile 62 ab. Dies ist für eine GUI, die eher auf Mausklicks wartet, eher exotisch, aber Fyne erlaubt es und ein Tastatur-Cowboy wie ich scheut die Maus wie der Teufel das Weihwasser. Mit "L" geht's nach rechts, mit "H" nach links, und "D" stülpt den Löschnapf über das aktuelle Foto und schickt es mit toTrash() in den Abfalleimer.

Die Eventschleife des Hauptprogramms, die erst die GUI auf den Schirm bringt und dann auf Eingaben des Users mit blitzschnell nachgeladenen Fotos reagiert, startet Zeile 87 mit win.ShowAndRun().

Array mit Löchern

Wie nun soll das Hauptprogramm die Liste mit Fotos effizient verwalten, durch die der User wie ein Derwisch durchfährt, und hie und da einen Eintrag löscht, den er anschließend nicht mehr sehen will? Ein Array wäre hier die falsche Datenstruktur, denn Arrays mit Löchern erfordern aufwändige Renovierungsarbeiten. Statt dessen zieht das Hauptprogramm die Standard-Library container/list herein und. Hier handelt es sich um eine doppelt verkettete Liste, in der das Programm schnell mit Next() zum folgenden und mit Prev() zum vorherigen Element wandern kann, auch wenn zwischenzeitlich Remove() beliebige Einträge löscht. Der Speicherbedarf für die gesamte Kollektion in images ist zwar durch die Verkettung etwas höher als bei einem Array, aber das performante Löschen beliebiger Elemente ohne Einbußen beim Durchschreiten ist den Aufwand allemal wert.

Abbildung 3: In einer doppelt verlinkten Liste kann der User trotz Löchern vor- und zurückfahren.

Die Funktionen scrollRight() und scrollLeft() in den Zeilen 90 und 99 liefern das jeweils nächste anzuzeigende Foto, wenn der User nach rechts ("L") oder links "H" manövriert. Selbst falls der User burschikos über die Enden hinweg fährt, tritt kein Fehler auf. Überschießt er nach rechts, springt scrollRight() mit Front() wieder zum Listenanfang, wandert er vom ersten Element weiter nach links, springt scrollLeft() zum letzten Listenelement.

Listing 3: image.go

    01 package main
    02 
    03 import (
    04   "fyne.io/fyne/v2"
    05   "fyne.io/fyne/v2/canvas"
    06   "fyne.io/fyne/v2/storage"
    07   "github.com/disintegration/imageorient"
    08   "github.com/nfnt/resize"
    09   "image"
    10   "strings"
    11 )
    12 
    13 const DspWidth = 1200
    14 const DspHeight = 800
    15 
    16 func dispDim(w, h int) (dw, dh int) {
    17   if w > h {
    18     // landscape
    19     return DspWidth, DspHeight
    20   }
    21 
    22   // portrait
    23   return DspHeight, DspWidth
    24 }
    25 
    26 func isImage(file fyne.URI) bool {
    27   ext :=
    28     strings.ToLower(file.Extension())
    29   return ext == ".jpg" || ext == ".jpeg"
    30 }
    31 
    32 func scaleImage(
    33   img image.Image) image.Image {
    34   dw, dh := dispDim(img.Bounds().Max.X,
    35     img.Bounds().Max.Y)
    36   return resize.Thumbnail(uint(dw),
    37     uint(dh), img, resize.Lanczos3)
    38 }
    39 
    40 func preloadImage(file fyne.URI) {
    41   if Cache.Contains(file) {
    42     return
    43   }
    44   go func() {
    45     img := loadImage(file)
    46     Cache.Add(file, img)
    47   }()
    48 }
    49 
    50 func showImage(
    51   img *canvas.Image, file fyne.URI) {
    52   e, ok := Cache.Get(file)
    53   var nimg *canvas.Image
    54   if ok {
    55     nimg = e.(*canvas.Image)
    56   } else {
    57     nimg = loadImage(file)
    58     Cache.Add(file, nimg)
    59   }
    60   img.Image = nimg.Image
    61 
    62   img.FillMode = canvas.ImageFillOriginal
    63 
    64   img.Refresh()
    65 }
    66 
    67 func loadImage(
    68   file fyne.URI) *canvas.Image {
    69   img := canvas.NewImageFromResource(nil)
    70 
    71   read, err :=
    72     storage.OpenFileFromURI(file)
    73   panicOnErr(err)
    74 
    75   defer read.Close()
    76   raw, _, err := imageorient.Decode(read)
    77   panicOnErr(err)
    78 
    79   img.Image = scaleImage(raw)
    80 
    81   return img
    82 }

Die Routinen zum Skalieren und Laden der Bildateien zeigt Listing 3. Um festzustellen, ob eine Datei ein JPG-Foto ist oder nicht, hilft isImage() ab Zeile 15, das anhand der Endung den Typ feststellt. Das Herunterskalieren großformatiger Handyfotos auf das Format 1200x800 übernimmt scaleImage() ab Zeile 21, das auf die Funktion resize() des nfnt-Pakets von Github zugreift. Der dort implementierte Algorithmus "Lanczos3" schrumpft die Handy-Fotos definitiv schneller als Fyne, wenn es feststellt, dass ein Bild zu groß für die Darstellung in einem zugewiesenen Widget ist.

Noch schneller dank Vorarbeit

Neu geladene Fotos zeigt showImage() ab Zeile 37 an, indem es versucht, sie erst aus dem Cache zu laden, und, falls dies fehlschlägt, sie mit loadImage() von der Platte kratzt und dekodiert. Das dauert freilich seine Zeit, muss aber eben manchmal sein. Wegen dieser Verzögerung von einer guten Sekunde nutzt preloadImage() ab Zeile 27 mit go func eine Goroutine, um den Ladevorgang im Hintergrund zu erledigen, während das Hauptprogramm weiterläuft und auf Usereingaben reagiert. Fordert der User das nächste Bild an, ist es meist schon im Cache und showImage() holt es ab und schickt es blitzschnell auf den Schirm.

Umsichtiger Cache

Dank des Pakets hashicorp/golang-lru von Github braucht sich der in der globalen Variablen Cache gehaltene LRU-Cache keine Gedanken über verschwendetes RAM zu machen. Zeile 23 in Listing 2 definiert einen Cache mit maximal 128 Einträgen für vorverarbeitete Bilder, in den Add() (Zeile 45, Listing 3) neue Elemente unter der Dateipfad-URL des Fotos einfügt und Get() (Zeile 39) sie wieder hervorholt. Zwar bietet der LRU-Cache die Funktion Contains(), die feststellt, ob sich ein Eintrag im Cache befindet, aber zur Vermeidung von Race-Conditions sollten Programmierer immer versuchen, einen Eintrag zu holen, falls sie ihn tatsächlich brauchen: Sonst könnte es sein, dass zwar Contains() meldet, dass der Eintrag vorhanden ist, doch ein nebenläufiges Programm könnte ihn bis zum folgenden Get() auch wieder verschwinden lassen.

Ist der Cache voll, wirft Add() gemäß den LRU-Regeln einfach den ältesten Eintrag aus dem Cache, bevor es den neuen einfügt. Für die App, die später vielleicht noch den alten Eintrag sucht, weil der User zurück zu diesem alten Foto gefahren ist, bedeutet dies keinen Beinbruch, denn sie kann das Bild einfach wieder von der Platte holen und neu in den Cache einfügen. Das dauert zwar ein bisschen länger, aber in diesem ungünstigen Fall muss der User halt warten.

Übrigens, noch eine Feinheit zu Gos strengem Typsystem: Container wie der LRU-Cache erlauben es, generische Datentypen einzuspeichern. Deswegen muss der Programmierer beim Hervorholen dafür sorgen, dass der Eintrag per Type-Assertion zur Laufzeit auch wieder den korrekten Typ erhält. So konvertiert zum Beispiel Zeile 42 einen gefundenen Cache-Eintrag in einen Pointer auf den Typ canvas.Image, denn das hervorgeholte Foto ist von diesem Typ, auch wenn der LRU-Cache ihn zwischenzeitlich als generischen interface{}-Typ gespeichert hatte.

Solcherlei Manipulationen hebeln natürlich Gos striktes Typsystem aus, und was Go normalerweise schon beim Compilieren abfängt, wird so zum ärgerlichen Laufzeitfehler, falls dergleichen Konvertierungen nicht sorgfältig getestet wurden.

Während Fyne übrigens normalerweise absolute Koordinatenwerte bei Layout-Instruktionen meidet und Widgets wie Buttons automatisch skaliert, allein aufgrund ihrer Beschriftung und dem verwendeten Font, ist dies bei Fotos nicht möglich, denn Fyne kann nicht wissen, wie groß das enthaltende Canvas-Objekt tatsächlich auf dem Bildschirm erscheinen soll. Wir Fyne für das Widget keine Mindestgröße in Form von SetMinSize() mitgibt, wird womöglich mit einer Lupe auf dem Bildschirm nach dem gesuchten Widget suchen. Ohne Vorgaben malt Fyne Bilder mit der Größe Null mal Null, was oft zu verblüffend schwierig zu findenden App-Fenstern führt. Wer die Mindestgröße vorgibt, wie Listing 3 in Zeile 66, sieht auch, was abgeht.

Listing 4: trash.go

    01 package main
    02 
    03 import (
    04   "os"
    05   "path/filepath"
    06 
    07   "fyne.io/fyne/v2"
    08 )
    09 
    10 const TrashDir = "old"
    11 
    12 func toTrash(file fyne.URI) {
    13   err := os.MkdirAll(TrashDir, 0755)
    14   panicOnErr(err)
    15   err = os.Rename(file.Name(),
    16     filepath.Join(TrashDir, file.Name()))
    17   panicOnErr(err)
    18 }
    19 
    20 func panicOnErr(err error) {
    21   if err != nil {
    22     panic(err)
    23   }
    24 }

Listing 4 schließlich implementiert den virtuellen Mülleimer, der mit toTash() Fotodateien in ein im Bedarfsfall neu erzeugtes Verzeichnis "old" abschiebt. Das erledigt die Funktion Rename() aus dem Standardpaket os, die solange klaglos funktioniert, wie Orignal und Zieldatei auf dem gleichen Speichermedium residieren.

Angepasstes Look-and-Feel

Damit sich Go die in den Listings verwendeten Fyne-Pakete vom Server auf fyne.io herunterlädt und die ganze Enchilada compiliert, hilft die Sequenz

    $ go mod init inuke
    $ go mod tidy

Ein darauffolgendes

    $ go build inuke.go image.go trash.go

sollte dann fehlerfrei ein Binary inuke erzeugen, das alle Fotos im aktuellen Verzeichnis der Reihe nach im Fenster anzeigt. Wie üblich in Go kann der Compiler auch für andere Plattformen cross-compilieren, und im Fall Fyne geht das sogar soweit, dass der Cross-Compiler das Look-and-Feel der anderen Plattform anschleppt. Wie das geht, steht ausführlich im Fyne-Buch vom Fyne-Chef persönlich ([5]). Der exakt gleiche Code der drei Listings compiliert auch anstandslos auf einem Mac und resultiert im leicht angpassten Apple-Look-and-Feel, wie aus Abbildung 4 ersichtlich.

Abbildung 4: Aus dem gleichen Code für den Mac compilierte App.

Die Implementierung graphischer Oberflächen hängt außerdem stark vom verwendeten Betriebssystem ab. Unter Linux klinkt sich Fyne mittels eines C-Wrappers aus Go in die Bibliotheken libx11-dev, libgl1-mesa-dev, libxcursor-dev und xorg-dev ein, die der User zum Beispiel auf Ubuntu mit sudo apt-get install nachinstallieren muss, damit ein darauffolgendes go build einer Fyne-App auch das notwendige Fundament findet.

Nobody is perfect

Perfekt ist die App allerdings noch nicht. Es soll tatsächlich noch Fotografen geben, die im Hochformat fotografieren, dafür wäre eine entsprechende Anpassung der fest eingestellten Fensterdimensionen notwendig. Auch kaprizieren sich manche Mobiltelefone darauf, Fotos unrotiert abzuspeichern und die Rotationsvorgabe lediglich im EXIF-Header des JPG-Fotos zu notieren. Entsprechende Anpassungen sind einfach zu erledigen, wie immer bei Open-Source-Projekten!

Infos

[1]

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

[2]

Markus Hoffmann, "Fynearbeit", Linux-Magazin 09/21, S. 78, <U>https://www.linux-magazin.de/ausgaben/2021/09/fyne/<U>

[3]

Michael Schilli, "Automatisch Ausmustern", Linux-Magazin 08/18, https://www.linux-magazin.de/ausgaben/2018/08/snapshot-5/, https://www.youtube.com/watch?v=ha8Gc2hJFdU

[4]

Andrew Williams, "Building Cross-Platform GUI Applications with Fyne", Packt Publishing 2021

[5]

Gebrüder Grimm, "Aschenputtel", https://de.wikipedia.org/wiki/Aschenputtel

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