Jürgen Bayer Informatik  
Home      Dienstleistungen      Referenzen      Kontakt     

Jürgen Bayer

Erratum zum C#-Codebook

Fehler und Verbesserungen in den Rezepten und neue Rezepte. Stand: 24.02.2004

Druckversion dieses Artikels (PDF)

Inhalt

1  Einige Worte zuvor 2  Dateisystem 2.1  Rezept 85: Dateiname mit anderer Endung ermitteln. Seite 256 3  Sonstiges 3.1  Rezept 242: Parameter an Threads übergeben und Ergebnisse auslesen 3.2  Rezept 244: Objekte nach XML serialisieren und von XML deserialisieren 3.3  Rezept 248: Abfragen der automatisch vergebenen Id eines neuen Datensatzes. Seite 692 4  Neue Rezepte 4.1  Internet 4.1.1  Mails über einen SMTP-Server mit Authentifizierung versenden 4.1.2  Probleme beim Downloaden von UTF- und Unicode-Dateien vermeiden 4.2  Formulare und Steuerelemente 4.2.1  In einem Mausereignis herausfinden, ob CTRL, ALT oder eine andere Taste(nkombination) betätigt wurde 4.2.2  List- und ComboBox mit zusätzlichen Daten 4.2.3  Verstehen des (auf den ersten Blick eigenartigen) ScrollBar-Verhaltens 4.2.4  Dafür sorgen, dass die Cursortasten in einem selbst entwickelten Steuerelement nicht dazu führen, dass der Fokus auf das nächste bzw. vorherige Steuerelement wechselt 4.3  Multimedia, Bilder und Grafiken 4.3.1  Pfeil zeichnen 4.3.2  Rechteck mit runden Ecken zeichnen 4.3.3  Gleichförmige Bitmaps korrekt stretchen 4.3.4  Zusätzliche Bildinformationen aus einer Grafikdatei lesen 4.3.5  Das Erzeugungsdatum eines Bildes auslesen

1  Einige Worte zuvor

Dieser Artikel enthält neben einigen mittlerweile verbesserten Rezepten meines C# Codebooks einige neue Rezepte, die ich im Laufe der Zeit in meiner Praxis entwickelt habe.

Sie können diese geänderten und neuen Rezepte in das Repository des Codebook übernehmen.

Kopieren Sie dazu den Ordner Repository der Buch-CD auf Ihre Festplatte und kopieren Sie die Dateien des Archivs, das Sie an der Adresse www.juergen-bayer.net/buecher/csharpcodebook/neues/repository-add-ons.zip downloaden können, in diesen Ordner. Beachten Sie, dass Sie beim Kopieren die Struktur der Unterordner beibehalten müssen.

In dieses geänderte Repository habe ich auch die Rezepte eingebaut, die zur Einführung zählen und deshalb nicht in das Repository auf der Buch-CD enthalten sind.

2  Dateisystem

2.1  Rezept 85: Dateiname mit anderer Endung ermitteln. Seite 256

Fehler

Die in diesem Rezept beschriebene Methode ChangeExtension führt zum einen dann zu Fehlern, wenn die zu ändernde Erweiterung mehrfach in einem Dateinamen vorkommt.

string filename = ChangeExtension(
   @"C:\Testfolder.tmp\Testfile.tmp", "txt")

führt z. B. zum Dateinamen "C:\Testfolder.txt\Testfile.txt".

Zum anderen ist diese Methode auch dann problematisch wenn die Endung eines Dateinamens ohne kompletten Pfad verändert werden soll, da sie ein FileInfo-Objekt und dessen FullName-Eigenschaft verwendet:

string filename = ChangeExtension(@"Testfile.tmp", "txt")

Wenn der Pfad zur Datei inkomplett ist, verwendet das FileInfo-Objekt einfach den Ordner, aus dem die Anwendung gestartet wurde. Der resultierende Dateiname ist also falsch.

Korrigierte Version

Eine Korrektur der ChangeExtension-Methode ist eigentlich sinnlos, denn Sie können zur Ermittlung eines Dateinamens mit einer anderen Endung die Methode ChangeExtension der Path-Klasse verwenden, die mir beim Schreiben des Codebook auf unerfindlichen Gründen (() nicht bekannt war:

public static string ChangeExtension(string filename, 
   string newExtension) 
{
   return System.IO.Path.ChangeExtension(filename, newExtension)
}

3  Sonstiges

3.1  Rezept 242: Parameter an Threads übergeben und Ergebnisse auslesen

Die Methode zur Primzahlberechnung in diesem Rezept arbeitet (nach einer E-Mail eines Lesers) zum einen nicht sehr performant, da sie die aktuelle Zahl nach jeder Teilung im Ergebnis-Steuerelement ausgibt. Ich habe die Ausgabe der Primzahl deswegen unter die innere Schleife verschoben.

Zum anderen ist der Zugriff auf das Steuerelement, das das jeweilige Ergebnis darstellen soll, potenziell fehlerhaft. Beim Multithreading unter Windows gilt nämlich der Grundsatz, dass nur der Thread, der ein Steuerelement erzeugt hat, auch auf dieses zugreifen darf. Im Normalfall ist dies der User-Interface(UI)-Thread (bzw. der Hauptthread der Anwendung). Greift ein Nicht-UI-Thread auf ein Steuerelement zu, so kann dies zu Exceptions führen. Wenn Sie sicher in einem Nicht-UI-Thread auf ein Steuerelement zugreifen wollen, müssen Sie die Invoke-Methode des Steuerelements dazu verwenden. Invoke leitet den Aufruf an den Thread weiter, der das Steuerelement erzeugt hat. Wie Sie dies programmieren, zeigt das verbesserte Rezept:

/* Klasse für den Thread */
private class PrimeNumberCalculator
{
   /* Argumente für die Ausführung der Thread-Methode */
   public long MinValue;
   public long MaxValue;
   public Control ResultControl;

   /* Delegate und Methode für die threadsichere Aktualisierung 
    * des Ergebnis- Steuerelements */
   private delegate void updateControlHandler(Control control, string text);
   private void updateControl(Control control, string text)
   {
      control.Text = text;
      control.Refresh();
   }

   /* Methode für den Thread */
   public void Calc()
   {
      // Berechnen aller Primzahlen von MinValue bis MaxValue
      for (long i = this.MinValue; i <= this.MaxValue; i++)
      {
         bool isPrimeNumber = true;
         for (long j = 2; j < i; j++)
         {
            if (i % j == 0)
            {
               isPrimeNumber = false;
               break;
            }
         }
         if (isPrimeNumber)
         {
            // Das Steuerelement threadsicher aktualisieren
            this.ResultControl.Invoke(
               new updateControlHandler(this.updateControl),
               new object[] {this.ResultControl, i.ToString()});
         }
      }
   }
}

3.2  Rezept 244: Objekte nach XML serialisieren und von XML deserialisieren

Erweiterung

Die im Rezept 244 beschriebenen Methoden serialisieren Objekte leider ohne eine korrekte Auflösung von Referenzen. Das folgende Beispiel macht dieses Problem deutlich. Die Klasse Country verwaltet die Daten eines Landes, die Klasse Countries ist eine Auflistung von Country-Objekten. Die Klasse City verwaltet die Daten einer Stadt, Cities ist wieder eine Auflistung. Ein City-Objekt besitzt eine Referenz auf ein Country-Objekt:

/// <summary>
/// Verwaltet die Daten eines Landes
/// </summary>
[Serializable]
public class Country
{
   public string Name;

   public Country(string name)
   {
      this.Name = name;
   }

   public Country()
   {
   }
}

/// <summary>
/// Auflistung für Country-Objekte
/// </summary>
[Serializable]
public class Countries: CollectionBase
{
   /// <summary>
   /// Fügt der Auflistung ein Country-Objekt hinzu
   /// </summary>
   public Country Add(Country obj)
   {
      base.InnerList.Add(obj);
      return obj;
   }

   /// <summary>
   /// Indexer zum Zugriff auf die referenzierten Country-Objekte
   /// </summary>
   public Country this[int index]
   {
      set
      {
         base.InnerList[index] = value;
      }

      get
      {
         return (Country)base.InnerList[index];
      }
   }
}

/// <summary>
/// Verwaltet die Daten einer Stadt
/// </summary>
[Serializable]
public class City
{
   public string Name;
   public Country Country;
   
   public City(string name, Country country)
   {
      this.Name = name;
      this.Country = country;
   }

   public City()
   {
   }
}

/// <summary>
/// Auflistung für City-Objekte
/// </summary>
[Serializable]
public class Cities: CollectionBase
{
/// <summary>
/// Fügt der Auflistung ein City-Objekt hinzu
/// </summary>
public City Add(City obj)
{
   base.InnerList.Add(obj);
   return obj;
}

/// <summary>
/// Indexer zum Zugriff auf die referenzierten City-Objekte
/// </summary>
public City this[int index]
{
   set
   {
      base.InnerList[index] = value;
   }

   get
   {
      return (City)base.InnerList[index];
   }
}

Wenn Sie nun eine Auflistung von City-Objekten erzeugen:

// Länder erzeugen
Countries countries = new Countries();
countries.Add(new Country("Australia"));
countries.Add(new Country("Germany"));
countries.Add(new Country("Italy"));

// Orte erzeugen
Cities cities = new Cities();
cities.Add(new City("Sydney", countries[0]));
cities.Add(new City("Melbourne", countries[0]));
cities.Add(new City("Berlin", countries[1]));
cities.Add(new City("Cologne", countries[1]));
cities.Add(new City("Rome", countries[2]));

und diese serialisieren:

string fileName = "C:\\temp\\Cities.xml"; 
XmlSerializer serializer = new XmlSerializer(cities.GetType());
StreamWriter streamWriter = new StreamWriter(fileName, false, 
   Encoding.UTF8);
serializer.Serialize(streamWriter, cities, null);
streamWriter.Close();

, so werden die Daten der referenzierten Länder in ein der jeweiligen Stadt untergeordnetes XML-Element geschrieben:

<?xml version="1.0" encoding="utf-8"?>
<ArrayOfCity xmlns:xsd="http://www.w3.org/2001/XMLSchema"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <City>
    <Name>Sydney</Name>
    <Country>
      <Name>Australia</Name>
    </Country>
  </City>
  <City>
    <Name>Melbourne</Name>
    <Country>
      <Name>Australia</Name>
    </Country>
  </City>
  <City>
    <Name>Berlin</Name>
    <Country>
      <Name>Germany</Name>
    </Country>
  </City>
  <City>
    <Name>Cologne</Name>
    <Country>
      <Name>Germany</Name>
    </Country>
  </City>
  <City>
    <Name>Rome</Name>
    <Country>
      <Name>Italy</Name>
    </Country>
  </City>
</ArrayOfCity>

Beim Deserialisieren erzeugt der XmlSerializer nun leider für jedes in einer Stadt enthaltene Land ein eigenes Country-Objekt:

string fileName = "C:\\temp\\Cities.xml";
StreamReader streamReader = new StreamReader(fileName, Encoding.UTF8);
cities = (Cities)serializer.Deserialize(streamReader);
streamReader.Close();

// Überprüfen, ob die ersten zwei Orte dasselbe Land referenzieren
Console.WriteLine("Überprüfe " + 
   cities[0].Name + ", " + cities[0].Country.Name  + " und " +
   cities[1].Name + ", " + cities[1].Country.Name);

if (cities[0].Country == cities[1].Country)
   Console.WriteLine("Beide Objekte referenzieren " +
      "dasselbe Country-Objekt");
else
   Console.WriteLine("Beide Objekte referenzieren " +
      "verschiedene Country-Objekte");

In diesem Beispiel referenzieren die beiden City-Objekte unterschiedliche Country-Objekte, was für die Praxis natürlich unbrauchbar ist, da es sich ja eigentlich um dasselbe Land handelt (eine Änderung der Daten eines Landes ist nun leider nur noch sehr schwer möglich).

Die Lösung des Problems ist die Verwendung der Klasse SoapFormatter, die ein Objekt in das SOAP-Protokoll formatiert. Das SOAP-Protokoll erlaubt die korrekte Auflösung von Beziehungen:

using System.Runtime.Serialization.Formatters.Soap;

...

// Die Orte SOAP-Serialisieren (ohne Ausnahmebehandlung)
string fileName = "C:\\temp\\Cities.xml"; 
SoapFormatter soapFormatter = new SoapFormatter();
FileStream fileStream = new FileStream(fileName, FileMode.Create);
soapFormatter.Serialize(fileStream, cities);
fileStream.Close();

// Die Orte wieder deserialisieren
fileStream = new FileStream(fileName, FileMode.Open);
cities = (Cities)soapFormatter.Deserialize(fileStream);
fileStream.Close();

// Überprüfen, ob die ersten zwei Orte dasselbe Land referenzieren
Console.WriteLine("Überprüfe " + 
   cities[0].Name + ", " + cities[0].Country.Name  + " und " +
   cities[1].Name + ", " + cities[1].Country.Name);

if (cities[0].Country == cities[1].Country)
   Console.WriteLine("Beide Objekte referenzieren dasselbe " +
      "Country-Objekt");
else
   Console.WriteLine("Beide Objekte referenzieren " +
      "verschiedene Country-Objekte");

In diesem Fall referenzieren beide City-Objekte nun dasselbe Country-Objekt und alles ist wieder in Ordnung (. Die einzigen Probleme der SOAP-Formatierung sind, dass die erzeute XML-Datei nun sehr komplex und unübersichtlich, dass diese mit einer Menge Overhead gespeichert wird (und somit mehr Platz beansprucht) und dass das Deserialisieren von (alten) Objektdaten leider nicht mehr möglich ist wenn Sie die Klasse ändern (z. B. neue Eigenschaften hinzufügen). Außerdem dürfte das Serialisieren und Deserialisieren mit SOAP etwas mehr Zeit in Anspruch nehmen.

3.3  Rezept 248: Abfragen der automatisch vergebenen Id eines neuen Datensatzes. Seite 692

Fehler

Die in diesem Rezept beschriebene Methode, die @@IDENTITY-Variable des SQL Servers für die Ermittlung der vom SQL Server automatisch ermittelten Id eines eingefügten Datensatzes zu verwenden, ist problematisch. Laut Aussagen eines Mitarbeiters einer befreundeten Firma kann @@IDENTITY einen falschen Wert speichern, wenn zwischen dem Einfügen des Datensatzes und der Abfrage von @@IDENTITY Datensätze über andere Verbindungen zur Datenbank eingefügt werden. Dieses Problem konnte ich allerdings nicht nachvollziehen. Nachvollziehbar ist allerdings das Problem, dass @@IDENTITY einen falschen Wert zurückliefert, wenn das Einfügen eines Datensatzes einen Trigger auslöst, der in einer anderen Tabelle mit Identity-Spalte Datensätze einfügt. @@IDENTITY gibt dann den Id-Wert des durch den Trigger eingefügten Datensatzes zurück.

Korrigierte Version

Statt @@IDENTITY können Sie im SQL Server 2000 die Funktion SCOPE_IDENTITY verwenden. Diese Funktion gibt den letzten Id-Wert bezogen auf den Gültigkeitsbereich zurück, aus dem heraus das Einfügen des Datensatzes erfolgte. Als Gültigkeitsbereich gelten z. B. Trigger, Stored Procedures oder Verbindungen. Fügt also eine Verbindung zur Datenbank einen Datensatz ein, führt SCOPE_IDENTITY zum korrekten Wert, auch wenn zwischen dem Einfügen und der Abfrage des ID-Werts eine andere Verbindung oder ein Trigger einen weiteren Datensatz eingefügt hat.

Das korrigierte Beispiel sieht dann so aus:

// Verbindung zur Bookstore-Datenbank auf dem lokalen SQL Server 
// aufbauen
SqlConnection connection = new SqlConnection("Server=(local);" +
   "Database=Bookstore;Trusted_Connection=Yes");
connection.Open();

// Autor hinzufügen
string sql = "INSERT INTO Authors (FirstName, LastName) " +
   "VALUES ('Matt', 'Ruff')";
SqlCommand command = new SqlCommand(sql, connection);
command.ExecuteNonQuery();

// Den Id-Wert auslesen
sql = "SELECT SCOPE_IDENTITY()";
command = new SqlCommand(sql, connection);
int identityValue = Convert.ToInt32(command.ExecuteScalar());

Console.WriteLine("Id des neuen Autors: {0}", identityValue);

4  Neue Rezepte

4.1  Internet

4.1.1  Mails über einen SMTP-Server mit Authentifizierung versenden

Zum Senden einer E-Mail über einen SMTP-Server, der eine Authentifizierung verlangt, bietet das .NET-Framework keine Möglichkeiten. Sie können kommerzielle Produkte, wie z. B. EasyMail (www.quiksoft.com/emdotnet) verwenden, die allerdings in der Regel einiges kosten. Eine sehr gute, umfangreiche und kostengünstige (weil konstenfreie) Alternative ist Indy (www.indyproject.org), eine Open Source TCP/IP-Socket-Komponente, die alle (!) wichtigen Internet-Protokolle unterstützt. Neben E-Mails können Sie mit Indy z. B. auch per FTP Dateien an einen FTP-Server übertragen und von diesem abrufen. Indy ist zwar etwas komplex, aber ziemlich cool, wenn man weiß wie es geht (.

Downloaden Sie die .NET-Assembly von der Seite www.indyproject.org/download/DotNet.html. Speichern Sie die Assembly (Indy.Sockets.dll) in einen ordner, den Sie für die Assemblies von Drittherstellern vorgesehen haben und referenzieren Sie diese in einem C#-Projekt. Das Senden einer Mail ist dann ganz einfach, wie ich es an dem folgenden Beispiel, das eine Mail in HTML-Form versendet, zeige:

// Methode für das OnStatus-Ereignis der SMTP-Klasse
private void smtpStatus(object sender, 
   Indy.Sockets.IndyComponent.Status status, string text) 
{
   this.lstStatus.Items.Add(text);
   this.lstStatus.Refresh();
}

// Methode für das Click-Ereignis des Senden-Schalters
private void btnSendMail_Click(object sender, System.EventArgs e)
{
   // Initialisierungen (hier müssen Sie Anpassungen vornehmen)
   string smtpHost = "mail.gmx.net";
   string username = "zaphod@gmx.de";
   string password = "galaxy";
   string from = "zaphod@gmx.de";
   string displayName = "Zaphod";
   string to = "ford@galaxy.net";
   string subject = "Test";
   string body = "<html><body><b>Das ist ein Test</b></body></html>";

   // SMTP-Instanz erzeugen und initialisieren
   Indy.Sockets.IndySMTP.SMTP smtp = new Indy.Sockets.IndySMTP.SMTP();
   smtp.Host = smtpHost;
   smtp.Username = username;
   smtp.Password = password;
   
   // Message erzeugen und initialisieren
   Indy.Sockets.IndyMessage.Message message = 
      new Indy.Sockets.IndyMessage.Message();
   // Betreff
   message.Subject = subject;
   // MIME-Typ für die Nachricht angeben (Voreinstellung : text/plain)   
   message.ContentType = "text/html";
   // Text der Nachricht
   message.Body.Text = body;
   // Sender der Nachricht
   message.From.Text = from;
   message.From.DisplayName = displayName;
   // Empfänger hinzufügen
   message.Recipients.Add().Text = to;
   
   // Das OnStatus-Ereignis zuweisen
   smtp.OnStatus += 
      new Indy.Sockets.IndyComponent.TIdStatusEvent(this.smtpStatus);
   
   // Verbindung aufbauen
   try
   {
      smtp.Connect();
   }
   catch (Exception ex)
   {
      MessageBox.Show("Fehler beim Verbindungsaufbau: " + ex.Message, 
         Application.ProductName, MessageBoxButtons.OK, 
         MessageBoxIcon.Error);
      return;
   }

   // Authentifizieren
   try
   {
      smtp.Authenticate();
   }
   catch (Exception ex)
   {
      MessageBox.Show("Fehler beim Authentifizieren: " + ex.Message, 
         Application.ProductName, MessageBoxButtons.OK, 
         MessageBoxIcon.Error);
      // Verbindung schließen
      smtp.Disconnect();
      return;
   }

   // Nachricht senden
   try
   {
      smtp.Send(message);
   }
   catch (Exception ex)
   {
      MessageBox.Show("Fehler beim Senden der Mail: " + ex.Message, 
         Application.ProductName, MessageBoxButtons.OK, 
         MessageBoxIcon.Error);
   }
   finally
   {
      // Verbindung schließen
      smtp.Disconnect();
   }
}

Das Beispiel setzt ein Formular mit einem Schalter btnSendMail und einer ListBox lstStatus voraus.

Beachten Sie, dass Sie aus lizenzrechtlichen Gründen bei der Verwendung von Indy einen Copyright-Hinweis in der Dokumentation der Anwendung, einer About-Box und/oder weiteren, mit der Anwendung mitgelieferten Dokumenten anbringen müssen (siehe www.indyproject.org/License/BSD.html).

4.1.2  Probleme beim Downloaden von UTF- und Unicode-Dateien vermeiden

Wenn Sie über ein HttpRequest-Objekt und dessen GetResponse-Methode eine UTF- oder Unicode-Datei downloaden, werden Sie u. U. Probleme bemerken. Dies ist z. B. dann der Fall, wenn Sie den Response-Stream über einen StreamReader mit der entsprechenden Codierung (z. B. UTF-8) lesen, die gelesenen Bytes mit der Content-Länge (ContentLength) des HttpReponse-Objekts vergleichen, und in dem Fall, dass die Anzahl der Bytes sich unterscheidet, eine Exception werfen. Das Problem liegt dann nämlich in der Tatsache, dass UTF- und Unicode-Dateien einen Header besitzen können (aber nicht müssen; bei UTF sind das drei Byte), der die Byte-Ausrichtung (Little Endian oder Big Endian) der Daten beschreibt. Der StreamReader liest aber nur die Bytes (Zeichen), die den Text der Datei speichern. HttpResponse.ContenLength gibt hingegen die Länge inklusive der Header-Bytes zurück. Und schon differiert die Anzahl der gelesenen von der Anzahl der im Response-Stream enthaltenen Bytes.

Eine direkte Lösung für dieses Problem habe ich bisher nicht gefunden. Leider ist die Länge des Response-Stream nicht abrufbar (die Eigenschaft Length generiert eine NotSupportedException). Als Hack lasse ich eine gelesene Anzahl minus der der Codierung entsprechenden Header-Bytes zu. Die Header-Bytes können Sie über die Methode GetPreamble des entsprechenden Encoding-Objekts ermitteln.

4.2  Formulare und Steuerelemente

4.2.1  In einem Mausereignis herausfinden, ob CTRL, ALT oder eine andere Taste(nkombination) betätigt wurde

ber die ModifierKeys-Eigenschaft der Control-Klasse erhalten Sie Informationen darüber, welche der „Modifizier-Tasten“ gerade betätigt ist. So können Sie z. B. abfragen, ob die STRG-Taste betätigt wurde:

if (Control.ModifierKeys == Keys.Control)

4.2.2  List- und ComboBox mit zusätzlichen Daten

Häufig müssen neben den in der Liste dargestellten Strings zusätzliche Daten in einer List- oder ComboBox verwaltet werden. Das ist z. B. dann der Fall, wenn Sie (Text-)Daten aus einer Datenbanktabelle auslesen und nach Auswahl der Daten deren Id-Wert (Primärschlüsselwert) weiterverarbeiten wollen. Zur Lösung dieses Problems erzeugen Sie eine Klasse, die die darzustellenden und die Zusatzdaten verwaltet. Die ToString-Methode (die von der List- bzw. ComboBox zum Ermitteln des darzustellenden Textes aufgerufen wird) gibt die darzustellenden Daten zurück:

public class Person
{
   // Eigenschaften
   public int Id;
   public string FirstName;
   public string LastName;

   // Überschreiben der ToString-Methode
   public override string ToString()
   {
      return this.FirstName + " " + this.LastName;
   }

   // Optionaler Konstruktor
   public Person(int id, string FirstName, string lastName)
   {
      this.Id = id;
      this.FirstName = FirstName;
      this.LastName = lastName;
   }
}

Wenn Sie nun Instanzen dieser Klasse an die Liste anhängen, können Sie beim Auslesen der Liste in den entsprechenden Typ konvertieren und die zusätzlichen Daten auslesen:

private void Form1_Load(object sender, System.EventArgs e)
{
   this.listBox1.Items.Add(new Person(1000, "Donald", "Duck"));
   this.listBox1.Items.Add(new Person(1001, "Daisy", "Duck"));
   this.listBox1.Items.Add(new Person(1002, "Dagobert", "Duck"));
}

private void listBox1_SelectedIndexChanged(object sender, 
   System.EventArgs e)
{
   Person p = (Person)this.listBox1.Items[this.listBox1.SelectedIndex];
   MessageBox.Show(p.Id.ToString());
}

4.2.3  Verstehen des (auf den ersten Blick eigenartigen) ScrollBar-Verhaltens

Die horizontale und die vertikale Scrollbar der System.Windows.Forms-Assembly weist ein auf den ersten Blick etwas eigenartiges Verhalten auf: Der Benutzer kann den Wert der Scrollbar nur bis Maximum - LargeChange + 1 scrollen. Eine Scrollbar mit LargeChange = 100 und Maximum = 1000 kann z. B. nur bis zum Wert 901 gescrollt werden. Der Sinn dieses Verhaltens ist, dass damit das Berechnen der korrekten maximalen Scrollposition erleichtert wird (so dass z. B. ein dargestelltes Bild immer nur soweit gescrollt werden kann, dass der rechte Rand des Bildes mit dem rechten Rand der Zeichenfläche übereinstimmt). Dummerweise stellen Programmierer LargeChange aber häufig auf einen anderen Wert ein als die Breite bzw. Höhe des Ausgabebereichs. Dann passt die von Microsoft voreingestellte Berechnung nicht mehr. Als Lösung stellen Sie den Wert von Maximum einfach auf den geplanten Maximalwert addiert mit LargeChange - 1 ein. Dann scrollt die ScrollBar immer bis Maximum.

4.2.4  Dafür sorgen, dass die Cursortasten in einem selbst entwickelten Steuerelement nicht dazu führen, dass der Fokus auf das nächste bzw. vorherige Steuerelement wechselt

In einem selbst entwickelten Steuerelement werden die Cursortasten per Voreinstellung so ausgewertet, dass der Eingabefokus je nach Taste auf das nächste bzw. vorherige Steuerelement gesetzt wird. Dieses Verhalten mag für einige Anwender (die die Tab-Taste nicht kennen ;-) hilfreich sein, ist aber sehr ärgerlich, wenn das Steuerelement die Cursortasten selbst (z. B. in OnKeyDown) auswertet.

Um diese implizite Behandlung auszuschalten müssen Sie die geerbte Methode IsInputKey überschreiben und für die gewünschten Tasten definieren, dass es sich um eine Eingabe-Taste handelt:

protected override bool IsInputKey(Keys key)
{
     switch(key)
     {
          case Keys.Up:
          case Keys.Down:
          case Keys.Right:
          case Keys.Left:
          return true;
     }
     return base.IsInputKey(key);
}

4.3  Multimedia, Bilder und Grafiken

4.3.1  Pfeil zeichnen

Einen einfachen Pfeil können Sie zeichnen, indem Sie die Endpunkte einer Linie passend einstellen:

Graphics g = e.Graphics;
Pen arrowPen = new Pen(Color.Black, 5);
arrowPen.StartCap = LineCap.SquareAnchor;
arrowPen.EndCap = LineCap.ArrowAnchor;
g.DrawLine(arrowPen, new Point(10, 10), new Point(100, 100));

4.3.2  Rechteck mit runden Ecken zeichnen

Das Zeichnen eines Rechtecks mit runden Ecken gehört nicht zu den Standards von GDI+. Die folgende Methode löst dieses Problem über einen Pfad, der zuerst entsprechend den übergebenen Parametern zu einem abgerundeten Rechteck zusammengesetzt und dann gefüllt und gezeichnet wird:

using System;
using System.Drawing;
using System.Drawing.Drawing2D;


...


public static void DrawRoundedRectangle(Graphics g, int x, int y, 
   int width, int height, int cornerRadius, Brush fillBrush, 
   Pen linePen)
{
   // Neuen GraphicsPath erzeugen ...
   GraphicsPath gp = new GraphicsPath();

   // ... und die Linien der Figur hinzufügen
   // Oben
   gp.AddLine(x + cornerRadius, y, x + width - cornerRadius, y);
   // Ecke rechts oben
   gp.AddArc(x + width - cornerRadius, y, cornerRadius, 
      cornerRadius, 270, 90);
   // Rechts
   gp.AddLine(x + width, y + cornerRadius, x + width, 
      y + height - cornerRadius);
   // Ecke rechts unten
   gp.AddArc(x + width - cornerRadius, y + height - cornerRadius,
      cornerRadius, cornerRadius, 0, 90);
   // Unten
   gp.AddLine(x + width - cornerRadius, y + height,
      x + cornerRadius, y + height);
   // Ecke links unten
   gp.AddArc(x, y + height - cornerRadius, cornerRadius,
      cornerRadius, 90, 90);
   // Links
   gp.AddLine(x, y + height - cornerRadius, x, y + cornerRadius);
   // Ecke links oben
   gp.AddArc(x, y, cornerRadius, cornerRadius, 180, 90);

   // Diese Figur abschließen (und eventuell weitere zeichnen)
   gp.CloseFigure();

   // Den Pfad mit dem übergebenen Pinsel füllen
   if (fillBrush != null)
      g.FillPath(fillBrush, gp);

   // Die Linien des Pfades zeichnen
   if (linePen != null)
      g.DrawPath(linePen, gp);
}

4.3.3  Gleichförmige Bitmaps korrekt stretchen

Häufig ist es notwendig, ein gleichförmiges Bitmap beim Zeichnen zu vergrößern. Dies ist z. B. dann der Fall, wenn Sie ein eigenes Button-Steuerelement entwickeln. Normalerweise verwenden Sie in diesem Fall ein Bitmap für den linken Rand, eines für den rechten und eines für die Mitte, das Sie dann entsprechend der Breite des Button stretchen (die Höhe des Buttons soll hier immer der Höhe des Bitmap entsprechen). Alternativ können Sie auch ein einziges Bitmap verwenden und die zu zeichnenden Teile bei der Angabe des Quell-Rechtecks entsprechend berücksichtigen.

Das Stretchen des Mittelteils führt aber immer dann zu Problemen, wenn Sie die in diesem Rezept beschriebene Technik nicht einsetzen. Der Default-Interpolation-Modus (der eigentlich für das Zoomen von echten Bildern gedacht ist) führt nämlich dazu, dass das ausgegebene Bild sehr unschön zum Rand hin heller wird. Auch andere InterpolationMode-Einstellungen führen nicht unbedingt zum gewünschten Ergebnis. Korrekt wäre für ein solches Stretchen auf jeden Fall zunächst der InterpolationMode NearestNeighbor, damit beim Stretchen die direkt anliegenden Pixel einfach wiederholt werden. Trotzdem führt das Stretchen dann immer noch zu Darstellungsproblemen wenn Sie einen mehr als 1 Pixel breiten (bzw. beim vertikalen Stretchen hohen) Teil des Quell-Bitmap stretchen. Der definitive Trick hierbei ist also, einfach nur einen 1 Pixel breiten (bzw. hohen) Teil des Quellbitmap auf dem Ziel-Objekt auszugeben.

Das folgende Beispiel (implementiert in OnPaint eines von Control abgeleiteten eigenen Steuerelements) zeichnet auf diese Weise einen Button, dessen Quell-Bitmap aus einer Datei eingelesen wird:

Bitmap bitmap = new Bitmap("C:\\Temp\\button.png");

// Die Grafik-Qualität für das gestreckte Zeichnen korrekt einstellen
e.Graphics.PixelOffsetMode = 
   System.Drawing.Drawing2D.PixelOffsetMode.Default;
e.Graphics.SmoothingMode = 
   System.Drawing.Drawing2D.SmoothingMode.Default;
e.Graphics.InterpolationMode = 
   System.Drawing.Drawing2D.InterpolationMode.NearestNeighbor;

// Breite eines linken bzw. rechten Teils des zu zeichnenden Schalters
const int PART_WIDTH = 20;

// Linken Rand ausgeben
e.Graphics.DrawImage(bitmap, new Rectangle(0, 0, PART_WIDTH, 
   bitmap.Height), 0, 0, PART_WIDTH, bitmap.Height, GraphicsUnit.Pixel);

// Rechten Rand ausgeben
e.Graphics.DrawImage(bitmap, new Rectangle(this.ClientRectangle.Width -
    PART_WIDTH, 0, PART_WIDTH, bitmap.Height),
    bitmap.Width - PART_WIDTH, 0, PART_WIDTH, bitmap.Height,
    GraphicsUnit.Pixel);

// Mitte (gezogen) ausgeben
int sourceX = (int)(bitmap.Width / 2F);
e.Graphics.DrawImage(bitmap, 
   new Rectangle(PART_WIDTH, 0, 
   this.ClientRectangle.Width - (PART_WIDTH * 2),
   bitmap.Height), sourceX, 0, 1, bitmap.Height, GraphicsUnit.Pixel);

4.3.4  Zusätzliche Bildinformationen aus einer Grafikdatei lesen

Viele Grafikformate verwalten nicht nur das eigentliche Bild, sondern auch zusätzliche Informationen zum Bild. Das JPEG-Format ist beispielsweise in der Lage, das Aufnahmedatum eines mit einer Digitalkamera aufgenommenen Bildes zu speichern. Diese Zusatzinformationen werden als Image Tag (Bild-Etikett) bezeichnet.

Diese Zusatzinformationen können Sie über die Eigenschaft PropertyItems der Bitmap-Klasse auswerten. Diese Auflistung verwaltet Instanzen der Klasse PropertyItem. Die Eigenschaft Id gibt als int-Wert an, um welche Information es sich handelt. Die Konstanten für die verschiedenen möglichen Id-Werte finden Sie bei Microsoft in der GDI+-Referenz: http://msdn.microsoft.com/library/en-us/gdicpp/GDIPlus/GDIPlusReference/Constants/ImagePropertyTagConstants.asp. Statt die Adrese einzugeben können Sie auch auf der Seite http://msdn.microsoft.com/library einfach nach „Image Property Tag Constants“ suchen. Die Beschreibungen der einzelnen Tag-Werte finden Sie über den Link Property Item Descriptions.

In der Eigenschaft Value wird der Wert der Information gespeichert, eigenartigerweise leider nicht als object, sondern als (nicht weiter dokumentiertes) Byte-Array. Die Eigenschaft Len gibt die Länge dieses Arrays an. Der Typ des Werts wird in der Eigenschaft Type als short-Wert verwaltet. Die verwendeten Typen und deren Konstanten finden Sie auf der Seite http://msdn.microsoft.com/library/en-us/gdicpp/GDIPlus/GDIPlusReference/Constants/ImagePropertyTagTypeConstants.asp.

Für Bildinformationen, die als String dargestellt werden können (Textinformationen, Datumswerte), verwaltet das Array eine nullterminierte (C++-) 8-Bit-Zeichenkette. Die Auswertung solcher Werte ist relativ einfach. Das folgende Beispiel liest (in einer Konsolenanwendung) alle Bilder eines Ordners und zu jedem Bild den Hersteller und das Modell des Geräts aus, über das das Bild erzeugt wurde, und das Datum der Aufzeichnung des Bildes:

using System;
using System.Drawing;
using System.Drawing.Imaging;
using System.Text;
using System.IO;

...

/// <summary>
/// Der Haupteinstiegspunkt für die Anwendung.
/// </summary>
[STAThread]
static void Main(string[] args)
{
   // Alle JPEG-Bilder aus C:\Temp einlesen
   DirectoryInfo di = new DirectoryInfo(@"C:\Temp");
   foreach (FileInfo file in di.GetFiles("*.jpg"))
   {
      // Bitmap für das Bild erzeugen
      string filename = file.FullName;
      Bitmap bitmap = new Bitmap(filename);

      // Name der Bilddatei ausgeben
      Console.WriteLine(file.Name);

      // Hersteller und Name des Geräts ermitteln, 
      // über den das Bild erzeugt wurde
      const int PropertyTagEquipMake = 0x010F;
      const int PropertyTagEquipModel = 0x110;
      string equipmentManufacturer = getTagValueAsString(bitmap,
         PropertyTagEquipMake);
      string equipmentModel = getTagValueAsString(bitmap,
         PropertyTagEquipModel);
      Console.WriteLine("Hersteller, Modell: {0} {1}", 
         equipmentManufacturer, equipmentModel);

      // Datum der Erzeugung des Bilds ermitteln
      const int PropertyTagDateTime = 0x0132;
      DateTime imageCreateDate = getTagValueAsDateTime(bitmap,
         PropertyTagDateTime);
      Console.WriteLine("Bild erzeugt am {0}", 
         imageCreateDate.ToString());

      // Bitmap freigeben, damit die Ressourcen nicht in Windows
      // reserviert bleiben (was bei meinen Tests leider öfter der
      // Fall war)
      bitmap.Dispose();

      Console.WriteLine();
   }
}

/// <summary>
/// Liefert den Wert einer Tag-Eigenschaft eines Bildes als 
/// String zurück
/// </summary>
private static string getTagValueAsString(Bitmap bitmap, int itemType)
{
   string result = null;   
   for (int i = 0; i < bitmap.PropertyItems.Length; i++)
   {
      PropertyItem item = bitmap.PropertyItems[i];
      if (item.Id == itemType)
      {
         for (int j = 0; j < item.Len - 1; j++)
         {
            result += (char)item.Value[j];
         }
         break;
      }
   }
   return result;
}

/// <summary>
/// Liefert den Wert einer Tag-Eigenschaft eines Bildes 
/// als DateTime zurück
/// </summary>
private static DateTime getTagValueAsDateTime(Bitmap bitmap, 
   int itemType)
{
   string result = getTagValueAsString(bitmap, itemType);
   if (result != null)
   {
      // Versuch, den im Format yyyy:MM:dd hh:mm:ss 
      // ermittelten String in ein Datum zu konvertieren
      if (result != "0000:00:00 00:00:00")
      {
         try
         {
            int year = Convert.ToInt32(result.Substring(0, 4));
            int month = Convert.ToInt32(result.Substring(5, 2));
            int day = Convert.ToInt32(result.Substring(8, 2));
            int hour = Convert.ToInt32(result.Substring(11, 2));
            int minute = Convert.ToInt32(result.Substring(14, 2));
            int second = Convert.ToInt32(result.Substring(17, 2));
            return new DateTime(year, month, day, hour, minute, 
               second);
         }
         catch
         {
            throw new Exception("Der String '" + result + 
               "' kann nicht in einen DateTime-Wert " +
               "konvertiert werden");
         }
      }
   }
   return new DateTime(0);
}

Die Methode getTagValueAsString liefert den String zurück, der in der Eigenschaft Value eines Informations-Werts gespeichert ist. Da es sich bei der dazu auszulesenden Eigenschaft PropertyItems leider nur um ein Array handelt, muss diese Methode das Array durchgehen um den gesuchten Wert zu finden. Wurde der Wert gefunden, erfolgt die Konvertierung in einen String einfach über das Durchgehen des Byte-Arrays, wobei das letzte Zeichen (das abschließende 0-Zeichen) nicht mit eingelesen wird.

Datumswerte werden scheinbar immer (jedenfalls bei den von mir getesteten JPEG-Bildern, die von fünf verschiedenen Kameras aufgeommen wurden) im (eigenartigen) Format yyyy:MM:dd hh:mm:ss zurückgegeben (was leider nicht dokumentiert ist). Als Sonderform kommt der String "0000:00:00 00:00:00" vor, der wohl für „kein Datum“ steht. Die Methode getTagValueAsDateTime versucht deshalb, den aus einer Tag-Information ausgelesenen String entsprechend in einen DateTime-Wert zu konvertieren.

Bei Informationen, die als Zahlwert gespeichert sind, wird die Auswertung etwas komplizierter. Das Byte-Array ist in diesem Fall lediglich eine Darstellung der für die Zahlwerte gespeicherten einzelnen Bytes. Für einen gespeicherten int-Wert enthält das Array also die vier Bytes, in der der int-Wert gespeichert ist. Einige Zahlwerte werden auch als rationale Zahl (Bruchzahl) verwaltet. Hier wird dann eigentlich ein Array aus zwei Zahlen vom entsprechenden Typ (z. B. long) verwaltet, wobei die erste der Zähler und die zweite der Nenner ist. Das Byte-Array enthält dann wieder lediglich die einzelnen Bytes dieser Zahlwerte.

In C++ ist die Auswertung von Zahlwerten ganz einfach. Dazu casten Sie das erste Byte des Byte-Array einfach in einen Zeiger des entsprechenden Typs und werten die Zahl dann über den Zeiger aus. Einen long-Wert können Sie in C++ dann z. B. so auslesen:

long* ptrLong = (long*)(pProp.value);
printf("Der Wert ist %d.\n", ptrLong[0]);

Einen als rationale Zahl dargestellten long-Wert können Sie in C++ so auslesen:

long* ptrLong = (long*)(pProp.value);
printf("Der Wert ist %d/%d.\n", ptrLong[0], ptrLong[1]);

In C# können Sie Zahlwerte auch auf diese Weise auswerten, dazu müssen Sie allerdings mit Zeigern in einem unsicheren Codeblock arbeiten (und damit die ganze Assembly mit der Option Unsafe kompilieren). Einfacher ist es, stattdessen die Methoden der Klasse BitConverter zu verwenden, die ein Byte-Array in den gewünschten Typ umwandeln.

Als Beispiel habe ich die Helligkeit eines Bildes gewählt, die als PropertyTagTypeSRational-Wert gespeichert ist. Ein solcher Wert soll laut der Dokumentation zwei long-Werte mit Vorzeichen verwalten, die eine rationale Zahl darstellen. Der erste ist der Nenner und der zweite der Zähler.

In meinen Tests wurden hier aber nicht zwei long- sondern zwei int-Werte gespeichert (was auch daran zu erkennen ist, dass das Byte-Array lediglich acht Bytes verwaltet). Daran erkennen Sie, dass Sie etwas vorsichtig mit der Dokumentation umgehen und selbst ausprobieren müssen. Zur Sicherheit fragt die folgende Methode zur Ermittlung der Helligkeit eines Bildes die Länge des Array ab und ermittelt das Ergebnis entsprechend:

/// <summary>
/// Liefert die Helligkeit eines Bildes als Double-Wert zurück
/// </summary>
private static double getBitmapBrightness(Bitmap bitmap)
{
   const int PropertyTagExifBrightness = 0x9203;
   double result = 0;
   for (int i = 0; i < bitmap.PropertyItems.Length; i++)
   {
      PropertyItem item = bitmap.PropertyItems[i];
      if (item.Id == PropertyTagExifBrightness)
      {
         // Die Werte für den Zähler (Numerator) und den 
         // Nenner (Denominator) ermitteln
         if (item.Len == 8)
         {
            // Zwei int-Werte für den Zähler und den Nenner
            // Anmerkung: Entspricht nicht der Dokumentation, 
            // kam in meinen Tests aber ausschließlich vor
            int numerator = BitConverter.ToInt32(item.Value, 0);
            int denominator = BitConverter.ToInt32(item.Value, 4);
         
            // Das Ergebnis berechnen
            result = numerator / (double)denominator;
         }
         else if (item.Len == 16)
         {
            // Zwei long-Werte für den Zähler und den Nenner
            // Anmerkung: Kam im Test nicht vor, ist aber laut der
            // Dokumentation die korrekte Variante
            long numerator = BitConverter.ToInt32(item.Value, 0);
            long denominator = BitConverter.ToInt32(item.Value, 4);
         
            // Das Ergebnis berechnen
            result = numerator / (double)denominator;
         }
         break;
      }
   }
   return result;
}

4.3.5  Das Erzeugungsdatum eines Bildes auslesen

Basierend auf dem vorherigen Rezept habe ich eine Methode geschrieben, die das Datum ermittelt, an dem ein Bild erzeugt (bzw. aufgezeichnet) wurde.

public static DateTime GetCreationDate(Bitmap bitmap)
{
   const int PropertyTagDateTime = 0x0132;

   string result = null;   
   for (int i = 0; i < bitmap.PropertyItems.Length; i++)
   {
      PropertyItem item = bitmap.PropertyItems[i];
      if (item.Id == PropertyTagDateTime)
      {
         // Den String ermitteln, der das Datum speichert
         for (int j = 0; j < item.Len - 1; j++)
         {
            result += (char)item.Value[j];
         }

         // Versuch, den im Format yyyy:MM:dd hh:mm:ss 
         // ermittelten String in ein Datum zu konvertieren
         if (result != "0000:00:00 00:00:00")
         {
            try
            {
               int year = Convert.ToInt32(result.Substring(0, 4));
               int month = Convert.ToInt32(result.Substring(5, 2));
               int day = Convert.ToInt32(result.Substring(8, 2));
               int hour = Convert.ToInt32(result.Substring(11, 2));
               int minute = Convert.ToInt32(result.Substring(14, 2));
               int second = Convert.ToInt32(result.Substring(17, 2));

               // Bitmap freigeben
               bitmap.Dispose();
               
               // Das Ergebnis zurückgeben
               return new DateTime(year, month, day, hour, minute, 
                  second);
            }
            catch
            {
               throw new Exception("Der String '" + result + 
                  "' kann nicht in einen DateTime-Wert " +
                  "konvertiert werden");
            }
         }

         break;
      }
   }
   
   // Das Datum existiert nicht
   bitmap.Dispose();
   return new DateTime(0);
}

© Jürgen Bayer 2005     
www.juergen-bayer.net