Aus Linux-Magazin 08/2025

Hervorhebungen in Screenshots per App erledigen

© KostiantynVoitenko / 123RF.com

Wer Screenshots von Nachrichten verschickt, möchte manchmal Stellen markieren. Mike Schilli erledigt das mit einer mausgesteuerten App in Go.

In herumgeschickten Ausschnitten aus Zeitungsartikeln lohnt es sich oft, eine Passage hervorzuheben, damit die Empfänger sofort wissen, worum es sich dreht und ob es sich lohnt, die Nachricht ganz zu lesen. Damals, im Papierzeitalter, griff der Absender zu einem grellgelb fluoreszierenden sogenannten Textmarker der Marke Stabilo, zog knackend dessen Kappe ab und strich mit dem nach Isopropylalkohol duftenden Filz einen Textbereich an, um ihn hervorzuheben. Es soll übrigens auch heutzutage noch solche Dinosaurier geben, denn kauft man ein Papierbuch online auf dem Gebrauchtmarkt, stellt sich oft heraus, dass weite Teile des Texts grell herausleuchten (Abbildung 1).

Abbildung 1: Ausschnitt aus einem gebraucht gekauften Buch mit Markierungen.

Abbildung 1: Ausschnitt aus einem gebraucht gekauften Buch mit Markierungen.

Liegt nun ein Artikel als Screenshot vor, kann ihn ein selbst geschriebenes Go-Programm mit GUI auf den Schirm bringen. Der User klickt mit der Maus auf einen Startpunkt und zieht dann bei gedrückt gehaltener Maustaste einen Rahmen auf, der sich wie ein Gummiband dehnt und den zu markierenden Textbereich umschließt (Abbildung 2). Das Fyne-Framework stellt dazu die benötigten grafischen Elemente in Go bereit. Sichert das Programm abschließend die modifizierte Datei, lässt sich diese umstandslos posten oder verschicken.

Abbildung 2: Gummibox aufziehen und Textstellen markieren.

Abbildung 2: Gummibox aufziehen und Textstellen markieren.

Bequemer durch Mehrarbeit

Dabei darf der User die Maus in alle Richtungen ziehen. Manchmal ist es eben bequemer, mit dem Zeichnen des Rahmens nicht links oben anzufangen und ihn nach rechts unten aufzuziehen, sondern exakt seitenverkehrt: also rechts unten anzufangen und nach links oben zu fahren – oder erst hoch und dann nach rechts. Die vier möglichen Aktionen zeigt Abbildung 3, und das Programm muss später die aufgezogene Box entsprechend richtig einzeichnen.

Abbildung 3: Die GUI kann Text in allen Richtungen markieren.

Abbildung 3: Die GUI kann Text in allen Richtungen markieren.

Grafik-Frameworks wie das hier verwendete Fyne zeichnen Rechtecke anhand der linken oberen Ecke »(x,y)« und den beiden Kantenlängen »w« und »h«. Daher muss die GUI später die Bewegungsrichtung der Maus beobachten, damit sie das Rechteck korrekt aufzieht. Fährt die Maus zum Beispiel von rechts unten nach links oben, verschiebt sich der Ursprung des zu zeichnenden Rechtecks vom Startpunkt der Bewegung in »(x0,x0)« nach »(x,y)« wie Abbildung 4 illustriert. Die Kantenlängen »w« und »h« ergeben sich aus der Differenz der horizontalen beziehungsweise vertikalen Koordinaten, also »x0 — x1« und »y0 — y1«, jeweils als Absolutwert.

Abbildung 4: Umgekehrt aufgezogen entsteht ein Rechteck, dessen Ursprung sich verschiebt.

Abbildung 4: Umgekehrt aufgezogen entsteht ein Rechteck, dessen Ursprung sich verschiebt.

Listing 1

marker.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/app"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/dialog"
  "fyne.io/fyne/v2/widget"
  "image"
  "os"
)
const (
  Width  = 800
  Height = 600
)
func main() {
  a := app.NewWithID("com.example.imagehighlighter")
  w := a.NewWindow("Image Highlighter")
  w.Resize(fyne.NewSize(Width, Height))
  ov := NewOverlay()
  img := &canvas.Image{}
  var big image.Image
  var imgPath string
  if len(os.Args) == 2 {
    imgPath = os.Args[1]
    f, err := os.Open(imgPath)
    if err != nil {
      panic(err)
    }
    big, img = ov.loadImage(f)
  }
  stack := container.NewStack(img, ov)
  w.Canvas().SetOnTypedKey(func(ev *fyne.KeyEvent) {
    key := string(ev.Name)
    switch key {
    case "Q":
      os.Exit(0)
    }
  })
  openBtn := widget.NewButton("Open Image", func() {
    dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) {
      if err != nil || reader == nil {
        return
      }
      defer reader.Close()
      imgPath = reader.URI().Path()
      big, img = ov.loadImage(reader)
      stack.Objects[0] = img
      stack.Refresh()
    }, w).Show()
  })
  saveBtn := widget.NewButton("Save", func() {
    ov.SaveBig(big, imgPath)
  })
  quitBtn := widget.NewButton("Quit", func() {
    os.Exit(0)
  })
  buttons := container.NewHBox(openBtn, saveBtn, quitBtn)
  w.SetContent(container.NewVBox(buttons, stack))
  w.ShowAndRun()
}

Auf den Schirm

Listing 1 zeigt das Hauptprogramm. Das kompilierte Binary heißt später »marker« und nimmt die Screenshot-Datei eines Zeitungsartikels auf der Kommandozeile als Argument entgegen. Als Formate taugen PNG- oder auch JPEG-Dateien. Alternativ bringt die GUI eine Dialogbox zur Dateiwahl hoch, falls der User auf den Button Open Image links oben im Fenster klickt (Abbildung 5). Dieses Dialog-Element bietet Fyne von Haus aus in der Abteilung »/dialog« des Projekts an. Zeile 41 lässt die Auswahlbox mit »NewFileOpen()« hochschnellen, falls der User durch einen Mausklick den Callback des Buttons »openBtn« ausgelöst hat.

Abbildung 5: Auf <span class="ui-element">Load Image</span> geklickt, und ein Dialog zum Einlesen eines Screenshots schnellt hoch.

Abbildung 5: Auf Load Image geklickt, und ein Dialog zum Einlesen eines Screenshots schnellt hoch.

Die vom Dialogfenster aus aufgerufene Callback-Funktion bekommt einen Reader vom Typ »fyne.URIReadCloser« mit, mit dem später die Funktion »loadImage()« die Daten der Bilddatei von der Platte holt. Vorher hat schon Zeile 32 einen neuen Containerstapel angelegt, der aus zwei übereinanderliegenden Layern besteht (Abbildung 6). Obenauf liegt die später in Listing 2 definierte »Overlay«-Struktur, auf der der User mit der Maus herumklicken darf und die gegebenenfalls das grellgrüne halbdurchsichtige Rechteck malt.

Abbildung 6: Der untere Fyne-Layer zeigt den Text, der obere markiert den Ausschnitt.

Abbildung 6: Der untere Fyne-Layer zeigt den Text, der obere markiert den Ausschnitt.

Die untere Schicht enthält die Screen-Darstellung der geladenen Bilddatei. Beide Layer bilden einen Verbund, und falls der obere Layer nichts Gegenteiliges definiert, scheint der untere Layer nach oben durch. So kommt es, dass der obere Layer unsichtbar bleibt (also vollständig lichtdurchlässig ist), während in der GUI das unten liegende Bild erscheint. Gleichzeitig bleibt der obere Layer klickbar. Lädt der Callback des Buttons Open Image ein neues Bild von der Platte, greift Zeile 48 mit »Objects[0]« in das Elemente-Array des Stack-Containers, fischt sich das erste Element heraus (auf Index 0) und ersetzt es durch das eben geladene Bild. Ein anschließender »Refresh()« auf dem Container zeigt nun die geänderte Sicht an.

Zeile 58 packt mit »NewHBox()« (H für Horizontal) die drei Buttons in eine Zeile. Das sind dann der eben erwähnte Schalter zum Laden neuer Dateien, ein weiterer, auf dem Save steht, der die Datei mitsamt neuer Markierung speichert, und einer mit der Aufschrift Quit, der das GUI-Programm auf Wunsch zusammenfaltet. Diese Button-Zeile findet sich wiederum vertikal oberhalb der Stack-Combo mit dem Bild (Abbildung 1), und »NewVBox()« packt beide vertikal gestapelt ins Hauptfenster, sodass die Button-Zeile über dem Bild schwebt. Dann bugsiert »Setcontent()« das Container-Konglomerat ins Hauptfenster der Applikation. »ShowAndRun()« in Zeile 60 startet die Event-Schleife, die Mausklicks abfängt und auf den einzelnen Widgets vordefinierte Aktionen einleitet, bis das Programm endet.

Über den Wolken

Listing 2 zeichnet für den oberen Layer der GUI verantwortlich, der die Mausbewegungen des Users beobachtet und entsprechend grün-gelbe Gummiboxen aufzieht. Die Funktion »ExtendBaseWidget()« im Konstruktor »NewOverlay()« macht aus der Anordnung ein Fyne-Widget, das Mausklicks mitbekommt und sich selbstständig rendern kann. Als Malfläche erzeugt Zeile 23 mit »NewWithoutLayout()« einen leeren Container, der später die Gummibox aufnimmt. Als Renderer zieht »CreateRenderer()« einfach den Standard-Renderer des verwendeten Containers heran – fertig ist das neue Custom-Widget.

Listing 2

overlay.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
  "github.com/disintegration/imaging"
  "image"
  "image/draw"
  "io"
)
type Overlay struct {
  widget.BaseWidget
  con      *fyne.Container
  marker   *canvas.Rectangle
  rect     *Rect
  inMotion bool
  zoom     float64
}
func NewOverlay() *Overlay {
  over := &Overlay{}
  over.ExtendBaseWidget(over)
  over.con = container.NewWithoutLayout()
  over.rect = NewRect()
  return over
}
func (t *Overlay) CreateRenderer() fyne.WidgetRenderer {
  return widget.NewSimpleRenderer(t.con)
}
func (t *Overlay) Dragged(e *fyne.DragEvent) {
  if t.inMotion == false {
    t.inMotion = true
    t.rect.From = e.Position
    return
  }
  t.rect.To = e.Position
  pos, size := t.rect.Dims()
  t.DrawMarker(pos, size)
}
func (t *Overlay) DragEnd() {
  t.inMotion = false
}
func (t *Overlay) DrawMarker(pos fyne.Position, size fyne.Size) {
  rect := canvas.NewRectangle(t.rect.Color())
  rect.Resize(size)
  rect.Move(pos)
  if t.marker == nil {
    t.marker = rect
    return
  }
  t.con.Remove(t.marker)
  t.marker = rect
  t.con.Add(rect)
  t.con.Refresh()
  return
}
func (t *Overlay) SaveBig(big image.Image, path string) error {
  dimg := imaging.Clone(big)
  r := t.rect.AsImage(t.zoom)
  draw.Draw(dimg, r, &image.Uniform{t.rect.Color()}, r.Min, draw.Over)
  err := imaging.Save(dimg, path)
  if err != nil {
    return err
  }
  return nil
}
func
 (t *Overlay) loadImage(r io.Reader) (image.Image, *canvas.Image) {
  big, err := imaging.Decode(r, imaging.AutoOrientation(true))
  if err != nil {
    panic(err)
  }
  shrunk := imaging.Resize(big, Width, 0, imaging.Lanczos)
  t.zoom = float64(big.Bounds().Dx()) / float64(Width)
  img := canvas.NewImageFromImage(shrunk)
  img.FillMode = canvas.ImageFillOriginal
  return big, img
}

Drückt nun der User die Maustaste und beginnt, mit gehaltener Taste ein Rechteck aufzuziehen, bekommt das Widget das Event »Dragged()« mit, das der Callback ab Zeile 30 verarbeitet. Das Event wiederholt sich laufend und sendet alle paar Millisekunden die aktuellen Koordinaten des Mauszeigers an die Funktion, während er über die Oberfläche fährt.

Mit dem Loslassen der Maustaste endet der Reigen, und das Event »DragEnd()« signalisiert das Ereignis der ab Zeile 40 eingehängten Funktion. Die setzt lediglich das Flag »inMotion« auf »false«, während der Callback »Dragged()« das Flag vorher auf »true« gesetzt hatte und dann von jeder neu angesteuerten Position aus die Funktion »DrawMarker()« ein neues Rechteck zeichnen ließ.

Flüssig wie Kintopp

Die Funktion merkt sich in der Instanzvariablen »marker« das zuletzt gezeichnete Rechteck. Sie löscht es eingangs mit »Remove()« aus dem Container, bevor der Container in Zeile 53 mit »Add()« ein neues Rechteck mit den aktuellen Koordinaten zugespielt bekommt. Die scheinbar flüssige Bewegung einer mit der Maus aufgezogenen Box beruht also auf einer Illusion aus schnell abgespielten Einzelbildern, ähnlich wie im Kino.

Listing 3

rect.go

package main
import (
  "fyne.io/fyne/v2"
  "image"
  "image/color"
)
type Rect struct {
  From fyne.Position
  To   fyne.Position
  Zoom float64
}
func NewRect() *Rect {
  return &Rect{}
}
func (r *Rect) Dims() (fyne.Position, fyne.Size) {
  x := r.From.X
  y := r.From.Y
  w := r.To.X - r.From.X
  h := r.To.Y - r.From.Y
  if r.To.X < r.From.X {
    x = r.To.X
    w = -w
  }
  if r.To.Y < r.From.Y {
    y = r.To.Y
    h = -h
  }
  return fyne.NewPos(x, y), fyne.NewSize(w, h)
}
func (r *Rect) AsImage(zoom float64) image.Rectangle {
  pos, size := r.Dims()
  x := pos.X * float32(zoom)
  y := pos.Y * float32(zoom)
  w := size.Width * float32(zoom)
  h := size.Height * float32(zoom)
  rect := image.Rectangle{
    Min: image.Point{X: int(x), Y: int(y)},
    Max: image.Point{X: int(x) + int(w), Y: int(y) + int(h)},
  }
  return rect
}
func (r *Rect) Color() color.NRGBA {
  return color.NRGBA{R: 204, G: 255, B: 0, A: 50}
}

Wie sich das Rechteck der Mausbewegung folgend ausbreitet, bestimmt die »Rect«-Struktur aus Listing 3 mit ihren Funktionen. Ausgehend von den Startkoordinaten »From.X« und »From.Y« und den Zielwerten »To.X« und »To.Y« gilt es, nach Abbildung 4 die Startkoordinaten des Rechtecks links oben »(X,Y)« zu ermitteln sowie dessen Breite »w« und Höhe »h«. Damit zieht das Fyne-Framework das Rechteck mit »Resize()« auf und schubst es mit »Pos()« an die richtige Stelle im Container. Je nach Fahrtrichtung der Maus kommen dabei negative Werte für die Rechteckdimensionen heraus, die die If-Logik in den Zeilen 22 und 26 gegebenenfalls in positive Werte korrigiert.

Zurück zum Original

Das Layer-Design eignet sich hervorragend, um auch kompliziertere Markierungen zuzulassen, zum Beispiel additive Auswahlen, um gleich mehrere Blöcke anzustreichen. Allerdings ist nun der Code des Originals verlustig gegangen, denn eingangs haben wir das Bild für die kompakte Darstellung auf dem Desktop geschrumpft. Außerdem steckt die Markierung in einem übergeordneten Layer. Wie flacht der Code das Bild dann später aus? Ziel ist es ja, die modifizierte Bilddatei mitsamt den Markierungen wieder auf der Platte abzulegen.

Abbildung 7: Die Streckung passt sowohl die Position als auch die Dimensionen des Rechtecks an.

Abbildung 7: Die Streckung passt sowohl die Position als auch die Dimensionen des Rechtecks an.

Die Originaldatei existiert noch im Speicher des Hauptprogramms, und die Markierung findet sich zwar in einem verkleinerten Maßstab wieder, doch das ist nichts, was eine zentrische Streckung nicht hinbiegen könnte. Abbildung 7 illustriert, dass sich auch die angebrachte Markierung entsprechend strecken muss, wenn wir das verkleinerte Bild wieder vergrößern. Und das bezieht sich nicht nur auf den linken oberen Startpunkt des Rechtecks, sondern auch auf dessen Länge und Breite.

Zoom mich zurück

Anfangs trimmte die Funktion »loadImage()« in Listing 2 das Bild auf die Breite von 800 Pixeln, und die Höhe ergab sich aus dem gleichbleibenden Seitenverhältnis. War die Originalbreite des Bilds X Pixel, ergibt sich daraus ein schrumpfender Zoom-Faktor von X/800. Um die Markierung im dargestellten Layer ins Originalbild zu übertragen, muss der Code die X/Y-Position und die Breite sowie Höhe der Markierung also wieder mit dem Zoom-Faktor multiplizieren.

Die Funktion »AsImage()« aus Listing 3 führt mit dem Zoom-Faktor als Argument die Streckung durch und spannt ab Zeile 36 ein Rechteck mit den neuen Dimensionen auf. Dabei ist zu beachten, dass Gos image-Paket die Koordinaten eines Rechtecks in einem anderen Format als das Fyne-Framework erwartet. Während Fyne die linke obere Ecke als Startpunkt und die Länge und Breite des Rechtecks verlangt, verwendet image den Startpunkt als »Min« im Format »image.Point« sowie die Koordinaten der rechten unteren Ecke in »Max«. Beides sind legitime Formate, aber Listing 2 muss eben zwischen beiden Welten übersetzen. Die grellgelbe Textmarker-Farbe definiert Zeile 43 in Listing 3 mit den RGB-Werten (204, 255, 0) sowie einem Alpha-Wert von 50 (255 maximal), damit darunterliegender Text noch lesbar durchscheint.

Um einen Screenshot mit Markierung wieder im Originalformat auf der Platte abzulegen, klont die Funktion »SaveBig()« in Listing 2 das Original-Bild in »big«, denn von Haus aus lässt Gos Bildstruktur »image.Image« keine Modifizierungen zu. Der »Clone()«-Befehl in Zeile 58 gibt eine Struktur zurück, die demselben Interface gehorcht, aber eben wie in Zeile 60 mit »Draw()« das Herummalen erlaubt. Der Parameter »draw.Over« stellt sicher, dass das gepinselte Rechteck wie in der GUI halbdurchsichtig über der Bilddatei erscheint.

Listing 4

build.sh

$ go mod init marker
$ go mod tidy
$ go build marker.go overlay.go rect.go

Der übliche Dreisprung aus Listing 4 verbindet alle Sourcen sowie die verwendeten Bibliotheken von Github zu einem Binary. Dann noch das fertige GUI-Programm in einen Pfad der Unix-Shell verfrachtet, wo es ein Aufruf von der Kommandozeile findet, und der Markierer kann loslegen. Wer möchte, baut noch zusätzliche Funktionen ein. Weitere Abschnitte anstreichen bei gedrückter Super-Taste? Speichern unter einem anderen Pfad? Kein Problem! Wer die Quellen hat, dem steht die Welt offen. (uba)

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 gern Ihre Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 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
Nach oben