Printing and Drawing with GDI+

Overview

The Microsoft .NET Framework includes a new model for two-dimensional drawing and printing. It's called GDI+, and it's represented by the types in the System.Drawing namespace. GDI+ isn't an entirely new drawing system—in fact, it's really just a .NET wrapper on top of the classic GDI (Graphics Device Interface) functions in the Microsoft Windows API. However, the GDI+ classes make it much easier to draw complex shapes, work with coordinates and transforms, and control image processing and rendering quality. GDI+ is also used for printing.

In this chapter, the recipes deal with two uses of GDI+. First you'll learn about printing, using GDI+ to print simple and complex documents (recipes 12.2 and 12.3). You'll also learn the techniques needed to center and wrap text (recipes 12.4 and 12.5), create a print preview (recipe 12.7), and retrieve information about print queues (recipes 12.8 and 12.9).

The second portion of this chapter deals with custom on-screen drawing and image manipulation, using shapes, fonts, bitmaps, and more. You'll learn how to convert images (12.11), how to use painting techniques (recipes 12.12 and 12.13), and how to remove flicker with double buffering (recipe 12.18). We'll also consider examples that show irregularly shaped windows (recipe 12.19) and owner-drawn controls (recipes 12.20 and 12.21).

Find All Installed Printers

Problem

You need to retrieve a list of available printers.

Solution

Read the names in the PrinterSettings.InstalledPrinters collection.

Discussion

The System.Drawing.Printing.PrinterSettings class encapsulates the settings for a printer and information about the printer. For example, you can use the PrinterSettings class to determine supported paper sizes, paper sources, and resolutions and check for the ability to print color or double-sided (or duplexed) pages. In addition, you can retrieve default page settings for margins, page orientation, and so on.

The PrinterSettings class provides a shared InstalledPrinters collection, which includes the name of every printer that's installed on the computer. If you want to find out more information about the settings for a specific printer, you simply need to create a PrinterSettings instance and set the PrinterName property accordingly.

The following code shows a Console application that finds all the printers that are installed on a computer and displays information about the paper sizes and the resolutions supported by each one. In order to use the example as written, you must import the System.Drawing.Printing namespace.

Public Module PrinterListTest Public Sub Main() Dim Printer As New PrinterSettings() Dim PrinterName As String For Each PrinterName In PrinterSettings.InstalledPrinters ' Display the printer name. Console.WriteLine("Printer: " & PrinterName) ' Retrieve the printer settings. Printer.PrinterName = PrinterName ' Check that this is a valid printer. ' (This step might be required if you read the printer name ' from a user-supplied value or a registry or configuration file ' setting.) If Printer.IsValid Then ' Display the list of valid resolutions. Console.WriteLine("Supported Resolutions:") Dim Resolution As PrinterResolution For Each Resolution In Printer.PrinterResolutions Console.WriteLine(" " & Resolution.ToString()) Next Console.WriteLine() ' Display the list of valid paper sizes. Console.WriteLine("Supported Paper Sizes:") Dim Size As PaperSize For Each Size In Printer.PaperSizes If System.Enum.IsDefined(Size.Kind.GetType, Size.Kind) Then Console.WriteLine(" " & Size.ToString()) End If Next Console.WriteLine() End If Next Console.ReadLine() End Sub End Module

Here's the type of output this utility displays:

Printer: HP LaserJet 5L Supported Resolutions: [PrinterResolution High] [PrinterResolution Medium] [PrinterResolution Low] [PrinterResolution Draft] [PrinterResolution X=600 Y=600] [PrinterResolution X=300 Y=300] Supported Paper Sizes: [PaperSize Letter Kind=Letter Height=1100 Width=850] [PaperSize Legal Kind=Legal Height=1400 Width=850] [PaperSize Executive Kind=Executive Height=1050 Width=725] [PaperSize A4 Kind=A4 Height=1169 Width=827] [PaperSize Envelope #10 Kind=Number10Envelope Height=950 Width=412] [PaperSize Envelope DL Kind=DLEnvelope Height=866 Width=433] [PaperSize Envelope C5 Kind=C5Envelope Height=902 Width=638] [PaperSize Envelope B5 Kind=B5Envelope Height=984 Width=693] [PaperSize Envelope Monarch Kind=MonarchEnvelope Height=750 Width=387] Printer: Generic PostScript Printer . . .

You don't necessarily need to take this approach when creating an application that provides printing features. As you'll see in recipe 12.2, you can use the PrintDialog to prompt the user to choose a printer and its settings. The PrintDialog class can automatically apply its settings to the appropriate PrintDocument without any additional code.

  Note

You can print a document in almost any type of application. However, your application must include a reference to the System.Drawing.dll assembly. If you are using a project type in Microsoft Visual Studio .NET that wouldn't normally have this reference (such as a Console application), you must add it.

Print a Simple Document

Problem

You need to print text or images.

Solution

Handle the PrintDocument.PrintPage event, and use the DrawString and DrawImage methods of the Graphics class to print data to the page.

Discussion

.NET uses an asynchronous event-based printing model. To print a document, you create a System.Drawing.Printing.PrintDocument instance, configure its properties, and then call its Print method, which schedules the print job. The common language runtime will then fire the BeginPrint, PrintPage, and EndPrint events of the PrintDocument class on a new thread. You handle these events and use the provided System.Drawing.Graphics object to output data to the page.

Printer settings are configured through the PrintDocument.PrinterSettings and PrintDocument.DefaultPageSettings properties. The PrinterSettings property returns a full PrinterSettings object (as described in recipe 12.1), which identifies the printer that will be used. The DefaultPageSettings property provides a full PageSettings object that specifies printer resolution, margins, orientation, and so on. You can configure these properties in code, or you can use the System.Windows.Forms.PrintDialog class to let the user make the changes using the standard Windows print dialog (shown in Figure 12-1).

Figure 12-1: The PrintDialog.

In the print dialog box, the user can select a printer and choose a number of copies. The user can also click the Properties button to configure advanced settings like page layout and printer resolution. Finally, the user can either accept or cancel the print operation by clicking OK or Cancel.

Before showing the PrintDialog, you must explicitly attach it to a PrintDocument object by setting the PrintDialog.Document property. Then, any changes the user makes in the print dialog will be automatically applied to the PrintDocument object. The next example creates a new document, allows the user to configure print settings, and then starts an asynchronous print operation. It all starts when the user clicks a button.

Private Sub cmdPrint_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles cmdPrint.Click ' Create the document and attach an event handler. Dim MyDoc As New PrintDocument() AddHandler MyDoc.PrintPage, AddressOf MyDoc_PrintPage ' Allow the user to choose a printer and specify other settings. Dim dlgSettings As New PrintDialog() dlgSettings.Document = MyDoc Dim Result As DialogResult = dlgSettings.ShowDialog() ' If the user clicked OK, print the document. If Result = DialogResult.OK Then ' This method returns immediately, before the print job starts. ' The PrintPage event will fire asynchronously. MyDoc.Print() End If End Sub

  Note

In some cases, you might show the PrintDialog simply to allow the user to configure settings, not to start a print job. In this case, your code would not call the Print method when the user clicks the OK button.

The actual printing logic is shown in the following code. In .NET, printing to a page is exactly the same as drawing to a window using GDI+. You use the same Graphics class, with its methods for drawing shapes, texts, and images. You must also track your position, because every Graphics class method requires explicit coordinates that indicate where to draw on the page. You can retrieve the page margins from the passed-in PrintPageEventArgs object.

Private Sub MyDoc_PrintPage(ByVal sender As Object, _ ByVal e As PrintPageEventArgs) ' Define the font. Dim MyFont As New Font("Arial", 30) ' Determine the position on the page. ' In this case, we read the margin settings ' (although there is nothing that prevents your code ' from going outside the margin bounds.) Dim x As Single = e.MarginBounds.Left Dim y As Single = e.MarginBounds.Top ' Determine the height of a line (based on the font used). Dim LineHeight As Single = MyFont.GetHeight(e.Graphics) ' Print five lines of text. Dim i As Integer For i = 0 To 4 ' Draw the text with a black brush, ' using the font and coordinates we have determined. e.Graphics.DrawString("This is line " & i.ToString(), _ MyFont, Brushes.Black, x, y) ' Move down the equivalent spacing of one line. y += LineHeight Next y += LineHeight ' Draw an image. e.Graphics.DrawImage(Image.FromFile(Application.StartupPath & _ " est.bmp"), x, y) End Sub

The printed document is shown, in Adobe Acrobat, in Figure 12-2.

Figure 12-2: The printed document.

This example has one limitation: it can only print a single page. To print more complex documents and span multiple pages, you will probably want to create a specialized class that encapsulates the document information, the current page, and so on. This technique is demonstrated in recipe 12.3.

Print a Document That Has Multiple Pages

Problem

You want to print complex documents with multiple pages and possibly print several different documents at once.

Solution

Place the information you want to print into a custom class that derives from PrintDocument, and set the PrintPageEventArgs.HasMorePages property to True as long as there are pages remaining.

Discussion

The PrintDocument.PrintPage event allows you to print only a single page. If you need to print more pages, you need to set the PrintPageEventArgs.HasMorePages property to True in the PrintPage event handler. As long as HasMorePages is True, the PrintDocument class will continue firing PrintPage events, one for each page. However, it's up to you to track what page you are on, what data should be placed on each page, and so on. To facilitate this tracking, it's a good idea to create a custom class.

The following example shows a class called TextDocument. This class inherits from PrintDocument and adds three properties. Text stores an array of text lines, PageNumber reflects the last printed page, and Offset indicates the last line that was printed from the Text array.

Public Class TextDocument Inherits PrintDocument Private _Text() As String Private _PageNumber As Integer Private _Offset As Integer Public Property Text() As String() Get Return _Text End Get Set(ByVal Value As String()) _Text = Value End Set End Property Public Property PageNumber() As Integer Get Return _PageNumber End Get Set(ByVal Value As Integer) _PageNumber = Value End Set End Property Public Property Offset() As Integer Get Return _Offset End Get Set(ByVal Value As Integer) _Offset = Value End Set End Property Public Sub New(ByVal text() As String) Me.Text = text End Sub End Class

Depending on the type of material you are printing, you might want to modify this class. For example, you could store an array of image data, some content that should be used as a header or footer on each page, font information, or even the name of a file from which you want to read the information. Encapsulating the information in a single class makes it easier to print more than one document at the same time.

The code that initiates printing is the same as in recipe 12.2, only now it creates a TextDocument instance instead of a PrintDocument instance:

' Generate some text. Dim PrintText(100) As String Dim i As Integer For i = 0 To 100 PrintText(i) = i.ToString() PrintText(i) &= " - lorum ipso facto lorum ipso facto lorum ipso" Next ' Create the custom document. Dim MyDoc As New TextDocument(PrintText) AddHandler MyDoc.PrintPage, AddressOf MyDoc_PrintPage ' Allow the user to choose the printer and settings. Dim dlgSettings As New PrintDialog() dlgSettings.Document = MyDoc Dim Result As DialogResult = dlgSettings.ShowDialog() If Result = DialogResult.OK Then MyDoc.Print() End If

The PrintPage event handler keeps track of the current line and checks if there is space on the page before attempting to print the next line. If a new page is needed, the HasMorePages property is set to True and the PrintPage event fires again for the next page. If not, the print operation is completed.

Private Sub MyDoc_PrintPage(ByVal sender As Object, _ ByVal e As PrintPageEventArgs) ' Retrieve the document that sent this event. ' You could store the document in a class member variable, ' but this approach allows you to use the same event handler ' to handle multiple print documents at once. Dim Doc As TextDocument = CType(sender, TextDocument) ' Define the font and determine the line height. Dim MyFont As New Font("Arial", 10) Dim LineHeight As Single = MyFont.GetHeight(e.Graphics) ' Create variables to hold position on page. Dim x As Single = e.MarginBounds.Left Dim y As Single = e.MarginBounds.Top ' Increment the page counter (to reflect the page that is about to be ' printed). Doc.PageNumber += 1 ' Print all the information that can fit on the page. ' This loop ends when the next line would go over the margin bounds, ' or there are no more lines to print. Do e.Graphics.DrawString(Doc.Text(Doc.Offset), MyFont, _ Brushes.Black, x, y) ' Move to the next line of data. Doc.Offset += 1 ' Move the equivalent of one line down the page. y += LineHeight Loop Until (y + LineHeight) > e.MarginBounds.Bottom Or _ Doc.Offset > Doc.Text.GetUpperBound(0) If Doc.Offset < Doc.Text.GetUpperBound(0) Then ' There is still at least one more page. ' Signal this event to fire again. e.HasMorePages = True Else ' Printing is complete. Doc.Offset = 0 End If End Sub

Print Centered Text

Problem

You want to center text vertically or horizontally on a page.

Solution

Calculate the available space between the margins, subtract the width or height of the text you want to print (using the Graphics.MeasureString and Font.GetHeight methods), and divide by two to find the appropriate coordinate.

Discussion

Printing centered text is easy. The only caveat is that you need to perform the coordinate calculations, as with all print operations.

Here is a code snippet that prints a block of three lines of text that are centered horizontally and vertically:

Private Sub MyDoc_PrintPage(ByVal sender As Object, _ ByVal e As PrintPageEventArgs) ' Define the font and text. Dim Font As New Font("Arial", 35) Dim Text As String = "This is a centered line of text." Dim LineHeight As Single = Font.GetHeight(e.Graphics) Dim LineWidth As Single = e.Graphics.MeasureString(Text, Font).Width ' Calculate the starting left and top coordinates. Dim x, y As Single x = (e.PageBounds.Width - LineWidth) / 2 y = (e.PageBounds.Height - LineHeight * 3) / 2 ' Print three lines of text. Dim i As Integer For i = 0 To 2 ' Draw the text with a black brush, ' using the font and coordinates we have determined. e.Graphics.DrawString(Text, Font, Brushes.Black, x, y) ' Move down the equivalent spacing of one line. y += LineHeight Next End Sub

There are actually two approaches to measure the height of a given line of text. You can use the height component returned from the Graphics.MeasureString method or the value returned from Font.GetHeight. These values are not necessarily the same—in fact, for most fonts the Font.GetHeight value will be slightly smaller.

For the purposes of line spacing, it's recommended that you always use Font.GetHeight. You should be aware, however, that there is more than one version of the GetHeight method. You must use the overloaded version that allows you to specify a Graphics object. Otherwise, the height you receive will be calculated for display purposes, not for the printer.

  Note

You can also print centered text by using an overloaded version of the DrawString method that takes a bounding rectangle, inside which you can center text. This approach surrenders some flexibility (for example, it won't help if you need to center mixed content that includes different fonts or graphics), but it requires less code. See recipe 12.5 for an example.

Print Wrapped Text

Problem

You want to parse a large block of text into distinct lines that fit on a page.

Solution

Use the Graphics.DrawString method overload that accepts a bounding rectangle. Or, if you need custom wrapping ability, write code that moves through the text word by word and measures it using the Graphics.MeasureString method until the line exceeds a set threshold.

Discussion

Often, you'll need to break a large block of text into separate lines that can be printed individually on a page. In .NET, you can take two approaches: one that performs the wrapping for you, and one by which your code controls the wrapping process.

To use automatic wrapping, you simply need to use the version of the Graphics.DrawString method that accepts a bounding rectangle. You specify a rectangle that represents where you want the text to be displayed. The text is then wrapped automatically to fit within those confines.

The following code demonstrates this approach using the bounding rectangle that represents the printable portion of the page. It prints a large block of text from a text box on the form. Notice that the shared RectangleF.op_Implicit method is used to convert the Rectangle structure that represents page margins to a RectangleF, which is required for the DrawString method.

Private Sub MyDoc_PrintPage(ByVal sender As Object, _ ByVal e As PrintPageEventArgs) ' Define the font and text. Dim Font As New Font("Arial", 15) e.Graphics.DrawString(txtData.Text, Font, Brushes.Black, _ RectangleF.op_Implicit(e.MarginBounds), StringFormat.GenericDefault) End Sub

The wrapped text is shown in Figure 12-3.

Figure 12-3: The printed document with wrapping.

You can also modify the StringFormat parameter to specify different options for the text alignment. For example, the next code snippet centers the block of text vertically on the page, and then centers each line of wrapped text.

Private Sub MyDoc_PrintPage(ByVal sender As Object, _ ByVal e As PrintPageEventArgs) ' Define the font and text. Dim Font As New Font("Arial", 15) Dim Format As StringFormat = StringFormat.GenericDefault ' Center the block of text on the page (vertically). Format.LineAlignment = StringAlignment.Center ' Center the individual lines of text (horizontally). Format.Alignment = StringAlignment.Center e.Graphics.DrawString(txtData.Text, Font, Brushes.Black, _ RectangleF.op_Implicit(e.MarginBounds), StringFormat.GenericDefault) End Sub

  Note

Although you can configure the StringFormat object to right-align text (using an Alignment value of StringAlignment.Far), the results will be far from ideal. This right-alignment does not take into account the precise size of the text based on font hinting and kerning, so the right margin of the text will not line up perfectly.

Complex document-oriented applications often require fine-grained control over the wrapping process. In this case, you can use an alternate approach and perform the text wrapping manually. Because most fonts are nonproportional (meaning each character has a different width), you can't use the number of characters to decide where to divide a sentence. Instead, you need to manually walk through the sentence character by character, looking for spaces. Every time a space is found, you add the current word to the line text, and decide whether or not to start printing the next line (based on its length).

The following code wraps a block of text manually. Notice that the string manipulation code uses the StringBuilder class whenever possible, which is faster when appending characters.

Private Sub MyDoc_PrintPage(ByVal sender As Object, _ ByVal e As PrintPageEventArgs) ' Define the font and text. Dim Font As New Font("Arial", 15) Dim LineHeight As Single = Font.GetHeight(e.Graphics) ' Create variables to hold position on page. Dim x As Single = e.MarginBounds.Left Dim y As Single = e.MarginBounds.Top ' Once the amount of border at the end of the line ' drops below the threshold, the line is wrapped ' (provided you are at the end of the word.) ' Depending on the font size, you will need to tweak the threshold. ' Generally, the larger the font, the larger the threshold ' will need to be to prevent a long word that will run off the page. Dim Threshold As Integer = 200 ' Retrieve data from a text box. Dim TextToPrint As New System.Text.StringBuilder(txtData.Text) ' Contains a single printed line. Dim Line As New System.Text.StringBuilder() Do ' Take one character from the text, and ' add it to the current line. Line.Append(TextToPrint.Chars(0)) TextToPrint = TextToPrint.Remove(0, 1) ' Check if you have reached a word break. If Line.Chars(Line.Length - 1) = " " Then ' It's time to decide whether to ' print the line or add another word. Dim LineString As String = Line.ToString() If e.Graphics.MeasureString(LineString, Font).Width > _ (e.PageBounds.Width - Threshold) Then e.Graphics.DrawString(LineString, Font, Brushes.Black, x, y) y += LineHeight Line = New System.Text.StringBuilder() End If End If Loop While TextToPrint.Length > 0 ' Print the last line. e.Graphics.DrawString(Line.ToString(), Font, Brushes.Black, x, y) End Sub

You can make this algorithm quite a bit more intelligent. Currently, it's possible (although unlikely) that an extremely large word could occur, taking the line from an accepted width to an unacceptable width (and simultaneously crossing both the threshold and the page boundary). You'll see that this becomes a problem for extremely large fonts.

To prevent this problem, you can check for this condition and then remove the added word if a problem occurs. In this case, you'd also need to check that no single word exceeds the width of the page, in which case the word can't be printed (without hyphenation, anyway). A really intelligent wrapping algorithm would compare the possible line lengths with or without a word and choose the option that deviates the least from the threshold. Finally, you could also add support for multipage printing by checking for the end of the page (as in recipe 12.3).

Print from a File

Problem

You want to print data from a file without loading it into memory first.

Solution

Use the PrintDocument.BeginPrint event to open a stream to the file and the PrintDocument.EndPrint event to close the stream.

Discussion

When printing a large document, you might want to save the memory overhead by printing the data as it's read from a stream. This way, the entire document doesn't need to be held in memory at the same time.

The easiest way to use this approach is to adopt the design from recipe 12.3 and derive a custom PrintDocument class. This class would track the filename of the document and hold a reference to the underlying file stream. This class would also encapsulate the logic for opening and closing the file stream by overriding the OnBeginPrint and OnEndPrint methods, which saves you from needing to handle the corresponding events.

The TextFileDocument class, shown in the following code, provides an example. TextFileDocument abstracts access to the underlying file stream by providing a ReadWord method that allows the printing code to retrieve the next word. This approach allows you to easily wrap the text as you print it.

Public Class TextFileDocument Inherits PrintDocument ' The filename that will be opened when the print job starts. Private _Filename As String ' Indicates if data is available in the stream. Private _DataAvailable As Boolean = False ' These private variables track the open file. ' The client using this class cannot access these details directly. Private Stream As System.IO.FileStream Private Reader As System.IO.StreamReader Public ReadOnly Property Filename() As String Get Return _Filename End Get End Property Public ReadOnly Property DataAvailable() As Boolean Get Return _DataAvailable End Get End Property Public Sub New(ByVal filename As String) ' Make sure we are using a fully-qualified path ' (in case the working directory changes before the print job starts). _Filename = System.IO.Path.GetFullPath(filename) If Not System.IO.File.Exists(_Filename) Then Throw New System.IO.FileNotFoundException(_Filename) End If End Sub Protected Overrides Sub OnBeginPrint( _ ByVal e As System.Drawing.Printing.PrintEventArgs) ' Open the file. Stream = New System.IO.FileStream(Filename, IO.FileMode.Open) Reader = New System.IO.StreamReader(Stream) _DataAvailable = True End Sub Protected Overrides Sub OnEndPrint( _ ByVal e As System.Drawing.Printing.PrintEventArgs) ' Close the file. Reader.Close() Stream.Close() Stream = Nothing Reader = Nothing _DataAvailable = False End Sub Public Function ReadWord() As String If Reader Is Nothing Then Throw New ApplicationException("File not open.") End If Dim Character As Char Dim CharNumber As Integer Dim Word As New System.Text.StringBuilder() ' This loop adds letters until a whole word is finished. ' The word is deemed complete when the file ends ' or a space is encountered. Do ' Read a single character out of the file. ' A -1 signals the end of the file. CharNumber = Reader.Read() If CharNumber <> -1 Then Character = Chr(CharNumber) Word.Append(Character) End If Loop Until Character = " " Or CharNumber = -1 ' Set a property to indicate when the file is out of data. If CharNumber = -1 Then _DataAvailable = False End If Return Word.ToString() End Function End Class

The client application creates a TextFileDocument instance (supplying the filename to read), attaches an event handler, and starts printing:

' Create the document and attach an event handler. Dim MyDoc As TextFileDocument Try MyDoc = New TextFileDocument(Application.StartupPath & " ext.txt") Catch Err As System.IO.FileNotFoundException MessageBox.Show("File not found.") Return End Try AddHandler MyDoc.PrintPage, AddressOf MyDoc_PrintPage ' Allow the user to choose a printer and specify other settings. Dim dlgSettings As New PrintDialog() dlgSettings.Document = MyDoc Dim Result As DialogResult = dlgSettings.ShowDialog() ' If the user clicked OK, print the document. If Result = DialogResult.OK Then ' This method returns immediately, before the print job starts. ' The PrintPage event will fire asynchronously. MyDoc.Print() End If

Finally, the printing code reads the words one by one and wraps the text manually.

Private Sub MyDoc_PrintPage(ByVal sender As Object, _ ByVal e As PrintPageEventArgs) ' Retrieve the TextFileDocument that is being printed. Dim Doc As TextFileDocument = CType(sender, TextFileDocument) ' Define the font and text. Dim Font As New Font("Arial", 15) Dim LineHeight As Single = Font.GetHeight(e.Graphics) ' Create variables to hold position on page. Dim x As Single = e.MarginBounds.Left Dim y As Single = e.MarginBounds.Top Dim Threshold As Integer = 200 ' Contains a single printed line. Dim Line As New System.Text.StringBuilder() Do ' Add one word to the current line. Line.Append(Doc.ReadWord()) ' It's time to decide whether to print the line or add another word. Dim LineString As String = Line.ToString() If e.Graphics.MeasureString(LineString, Font).Width > _ (e.PageBounds.Width - Threshold) Then e.Graphics.DrawString(LineString, Font, Brushes.Black, x, y) y += LineHeight Line = New System.Text.StringBuilder() End If Loop While Doc.DataAvailable ' Print the last line. e.Graphics.DrawString(Line.ToString(), Font, Brushes.Black, x, y) End Sub

Display a Dynamic Print Preview

Problem

You want to use an on-screen preview that shows how a printed document will look.

Solution

Use the PrintPreviewControl or PrintPreviewDialog.

Discussion

As described earlier, the code used to print a document to a page is almost identical to the code used to draw graphics on a window. This makes it possible for you to create code that can print or draw a print preview with equal ease. However, you don't need to go to this extra step because .NET already provides a control that can take a print document, run your printing code, and use it to generate a graphical on-screen preview. In fact, .NET provides two such controls: PrintPreviewDialog, which shows a preview in a standalone window, and PrintPreviewControl, which shows a preview in one of your own custom forms.

To use a standalone print preview, you simply create a PrintPrevewDialog object, assign the document, and call the PrintPreviewDialog.Show method.

Dim dlgPreview As New PrintPreviewDialog() dlgPreview.Document = MyDoc dlgPreview.Show()

The print preview window (shown in Figure 12-4) provides all the controls the user needs to move from page to page, zoom in, and so on. The window even provides a print button that allows the user to send the document directly to the printer. You can tailor the window to some extent by modifying the PrintPreviewDialog properties.

Figure 12-4: The PrintPreviewDialog.

You can also add the PrintPreviewControl to any of your forms to show a preview alongside other information. Visual Studio .NET will then add the following control declaration to the designer code:

Friend WithEvents Preview As System.Windows.Forms.PrintPreviewControl

In this case, you don't need to call the Show method. As soon as you set the PrintPreviewControl.Document property, the preview is generated. To clear the preview, set the Document property to Nothing, and to refresh the preview, simply reassign the Document property.

Preview.Document = MyDoc

PrintPreviewControl only shows the preview pages, not any additional controls. However, you can add your own controls for zooming, tiling multiple pages, and so on. You simply need to adjust the PrintPreviewControl properties accordingly.

For example, the following code changes the zoom to 20 percent.

Preview.Zoom = 0.2

This code shows a two-by-three grid of pages (allowing up to six pages to be shown at the same time):

Preview.Columns = 2 Preview.Rows = 3

Figure 12-5 shows the PrintPreviewControl in a custom window with some added controls for zooming.

Figure 12-5: The PrintPreviewControl in a custom window.

Retrieve Print Queue Information

Problem

You want to determine the status of all jobs in the print queue.

Solution

Use Windows Management Instrumentation to perform a query with the Win32_PrintJob class.

Discussion

Windows Management Instrumentation, or WMI, allows you to retrieve a vast amount of system information using a query-like syntax. One of the tasks you can perform with WMI is to retrieve a list of outstanding print jobs, along with information about each one.

In order to use WMI, you need to add a reference to the System.Management.dll assembly. You should then import the System.Management namespace. The following code assumes you have added a reference to the System.Management.dll assembly. It retrieves a list of all the print jobs in the queue and displays information about each one in the Console window.

Public Module PrintQueueTest Public Sub Main() ' Select all the outstanding print jobs. ' You could customize this expression to get jobs from a ' specific printer or user. Dim Query As String = "SELECT * FROM Win32_PrintJob" Dim JobQuery As New ManagementObjectSearcher(query) Dim Jobs As ManagementObjectCollection = JobQuery.Get() ' Display information for all jobs in the queue. Dim Job As ManagementObject For Each Job In Jobs Console.WriteLine("Caption: " & Job("Caption")) Console.WriteLine("DataType: " & Job("DataType")) Console.WriteLine("Description: " & Job("Description")) Console.WriteLine("Document: " & Job("Document")) Console.WriteLine("DriverName: " & Job("DriverName")) Console.WriteLine("ElapsedTime: " & Job("ElapsedTime")) Console.WriteLine("HostPrintQueue: " & Job("HostPrintQueue")) Console.WriteLine("InstallDate: " & Job("InstallDate")) Console.WriteLine("JobId: " & Job("JobId").ToString()) Console.WriteLine("JobStatus: " & Job("JobStatus")) Console.WriteLine("Name: " & Job("Name")) Console.WriteLine("Notify: " & Job("Notify")) Console.WriteLine("Owner: " & Job("Owner")) Console.WriteLine("PagesPrinted: " & _ Job("PagesPrinted").ToString()) Console.WriteLine("Parameters: " & Job("Parameters")) Console.WriteLine("PrintProcessor: " & Job("PrintProcessor")) Console.WriteLine("Priority: " & Job("Priority").ToString()) Console.WriteLine("Size: " & Job("Size").ToString()) Console.WriteLine("StartTime: " & Job("StartTime")) Console.WriteLine("Status: " & Job("Status")) Console.WriteLine("StatusMask: " & Job("StatusMask").ToString()) Console.WriteLine("TimeSubmitted: " & Job("TimeSubmitted")) Console.WriteLine("TotalPages: " & Job("TotalPages").ToString()) Console.WriteLine("UntilTime: " & Job("UntilTime")) Next Console.ReadLine() End Sub End Module

Here's the sample output you might see if the queue holds a single job:

Caption: Acrobat Distiller, 58 DataType: RAW Description: Acrobat Distiller, 58 Document: http://www.google.ca/ DriverName: AdobePS Acrobat Distiller ElapsedTime: HostPrintQueue: \FARIAMAT InstallDate: JobId: 58 JobStatus: Paused | Printing Name: Acrobat Distiller, 58 Notify: Matthew Owner: Matthew PagesPrinted: 0 Parameters: PrintProcessor: WinPrint Priority: 1 Size: 2293760 StartTime: Status: Degraded StatusMask: 17 TimeSubmitted: 20030117095826.632000-300 TotalPages: 1 UntilTime:

  Note

To test this code, you might want to pause a printer (using the Printers and Faxes window) and then print a document. As long as the printer is paused, the document will remain in the queue.

This example assumes that you don't have Option Explicit enabled. If you do, you will need to check for a null reference before you can retrieve the Job properties, and then call the ToString method to convert each piece of the job information to a string. For example, the InstallDate field is not always available. In order to display this information if it exists without causing an error if it doesn't, you must use the following lengthier code:

Console.WriteLine("InstallDate: ") If Not Job("InstallDate") Is Nothing Console.WriteLine(Job("InstallDate").ToString()) End If

Manage Print Jobs

Problem

You want to pause or resume a print job or print queue.

Solution

Use the Pause and Resume methods of the WMI Win32_PrintJob and Win32_Printer classes.

Discussion

WMI isn't limited to information retrieval tasks (such as those shown in recipe 12.8). You can also execute certain WMI class methods to interact with the Windows operating system. One example is the Pause and Resume methods that allow you to manage printers and print jobs.

The following Console application provides a straightforward example. Every time the application finds a print job in the queue, it checks to see if the job or the printer is paused. If so, it tries to resume the job or the printer. Note that Windows permissions might prevent you from pausing or removing a print job created by another user. In fact, depending on the permissions of the current user account, a System.Management.ManagementException exception may be thrown when you attempt to retrieve status information for a print job.

Public Module PrintQueueTest Public Sub Main() ' Select all the outstanding print jobs. Dim Query As String = "SELECT * FROM Win32_PrintJob" Dim JobQuery As New ManagementObjectSearcher(Query) Dim Jobs As ManagementObjectCollection = JobQuery.Get() ' Examine all jobs in the queue. Dim Job As ManagementObject For Each Job In Jobs ' Check if the job is paused (has Status 1). If (CType(Job("StatusMask").ToString(), Integer) And 1) = 1 Then Console.WriteLine("Job is paused. Attempting to resume.") ' Attempt to resume the job. Dim ReturnValue As Integer ReturnValue = CType( _ Job.InvokeMethod("Resume", Nothing).ToString(), Integer) ' Display information about the return value. If ReturnValue = 0 Then Console.WriteLine("Successfully resumed job.") ElseIf ReturnValue = 5 Then Console.WriteLine("Access denied.") Else Console.WriteLine( _ "Unrecognized return value when resuming job.") End If End If ' Find the corresponding printer for this job. Query = "SELECT * FROM Win32_Printer WHERE DriverName='" & _ Job("DriverName") & "'" Dim PrinterQuery As New ManagementObjectSearcher(Query) Dim Printers As ManagementObjectCollection = PrinterQuery.Get() ' Examine each matching printer (should be exactly one match). Dim Printer As ManagementObject For Each Printer In Printers ' Check if the printer is paused ' (has ExtendedPrinterStatus 8). If (CType(Printer("ExtendedPrinterStatus").ToString(), _ Integer) And 8) = 8 Then Console.WriteLine("Printer is paused. " & _ "Attempting to resume.") ' Attempt to resume the printer. Dim ReturnValue As Integer ReturnValue = Val(Printer.InvokeMethod( _ "Resume", Nothing).ToString()) ' Display information about the return value. If ReturnValue = 0 Then Console.WriteLine( _ "Successfully resumed printing.") ElseIf ReturnValue = 5 Then Console.WriteLine("Access denied.") Else Console.WriteLine( _ "Unrecognized return value when resuming printer.") End If End If Next Next Console.ReadLine() End Sub End Module

Other WMI methods that you might use in a printing scenario include AddPrinterConnection, SetDefaultPrinter, CancelAllJobs, and PrintTestPage, all of which work with the Win32_Printer class. For more information about using WMI to retrieve information about Windows hardware, refer to the MSDN documentation at http://msdn.microsoft.com/library/en-us/wmisdk/wmi/computer_system_hardware_classes.asp.

  Note

The Pause and Resume methods might not be supported in versions of Windows earlier than Windows XP. In that case, you might need to use Windows API functions such as the SetPrinter function from winspool.drv.

Find All Installed Fonts

Problem

You want to retrieve a list of all the fonts on the current computer.

Solution

Create a new instance of the System.Drawing.Text.InstalledFontCollection class, which contains a collection of FontFamily objects representing all the installed fonts.

Discussion

The InstalledFontCollection class allows you to retrieve information about currently installed fonts. The following code uses this technique to fill a list box named lstFonts with a list of valid font names:

Dim FontFamilies As New System.Drawing.Text.InstalledFontCollection() Dim Family As FontFamily For Each Family In FontFamilies.Families lstFonts.Items.Add(Family.Name) Next

When an entry is chosen in the list box, the font of a label control is updated:

Private Sub lstFonts_SelectedIndexChanged(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles lstFonts.SelectedIndexChanged lblSample.Font = New Font(lstFonts.Text, 30) End Sub

Figure 12-6 shows a screen shot of this simple test application.

Figure 12-6: Testing fonts.

Convert the Format of an Image

Problem

You want to batch convert the format of one or more image files.

Solution

Use the Image.FromFile method to load the image, and use the Image.Save method overload that allows you to specify a new ImageFormat.

Discussion

.NET includes an impressive set of image-processing features. The core class is System.Drawing.Image, which allows you to open and save multiple types of images. The Image class supports the following formats:

These formats are defined by the ImageFormat class in the System.Drawing.Imaging namespace. Using the Image class and the ImageFormat class, you can specify a new format to use when saving a file. The Console application shown here uses this technique—it reads all the BMP files in a directory and saves JPEG versions of each one. In order to use this code as written, you must import the System.Drawing, System.Drawing.Imaging and System.IO namespaces.

Public Module ImageConverter Public Sub Main() Dim Dir As New DirectoryInfo(Directory.GetCurrentDirectory()) Console.WriteLine("Processing images in " & Dir.FullName) ' Retrieve all the bitmap files in this directory. Dim File As FileInfo For Each File In Dir.GetFiles("*.bmp") Console.WriteLine("Converting: " & File.Name) ' Load the image into memory. Dim Image As Image = Image.FromFile(File.FullName) ' Create a new filename. Dim JpgName As String JpgName = Path.GetFileNameWithoutExtension(File.FullName) & ".jpg" ' Save the filename as a JPEG. Image.Save(JpgName, ImageFormat.Jpeg) Console.WriteLine("Saved: " & JpgName) Console.WriteLine() Next Console.ReadLine() End Sub End Module

Not only can you convert between any image file types, but you can also specify additional parameters that influence how the image data is processed. For example, you might change the compression of a TIFF file to the quality of a JPEG file. You can do this using Encoder objects, which you supply to an overloaded version of the Image.Save method. Many useful Encoder objects can be retrieved from the shared properties of the System.Drawing.Imaging.Encoder class. These properties include ChrominanceTable, ColorDepth, Compression, LuminanceTable, Quality, RenderMethod, SaveFlag, ScanMethod, Transformation, and Version.

More information is available in the MSDN reference. In addition, the next example shows how you can use an Encoder object to save JPEG files with different quality parameters.

Public Module ImageConverter Public Sub Main() ' Get an ImageCodecInfo object that represents the JPEG codec. ' This is accomplished by searching for the corresponding MIME type. Dim CodecInfo As ImageCodecInfo = GetEncoderInfo("image/jpeg") ' Create an Encoder object based for the Quality parameter. Dim Enc As Encoder = Encoder.Quality ' Create the array that will hold all encoding parameters. ' In this case, it will only hold the quality parameter. Dim EncParams As New EncoderParameters(1) Dim Dir As New DirectoryInfo(Directory.GetCurrentDirectory()) Console.WriteLine("Processing images in " & Dir.FullName) ' Retrieve all the bitmap files in the current directory. Dim File As FileInfo For Each File In Dir.GetFiles("*.bmp") Console.WriteLine("Converting: " & File.Name) ' Load the image into memory. Dim Image As Image = Image.FromFile(File.FullName) ' Create a new filename. Dim JpgName As String ' Save the bitmap as a JPEG file with quality level 25. EncParams.Param(0) = New EncoderParameter(Enc, 25L) JpgName = Path.GetFileNameWithoutExtension(File.FullName) & _ "25" & ".jpg" Image.Save(JpgName, CodecInfo, EncParams) Console.WriteLine("Saved: " & JpgName) ' Save the bitmap as a JPEG file with quality level 50. EncParams.Param(0) = New EncoderParameter(Enc, 50L) JpgName = Path.GetFileNameWithoutExtension(File.FullName) & _ "50" & ".jpg" Image.Save(JpgName, CodecInfo, EncParams) Console.WriteLine("Saved: " & JpgName) ' Save the bitmap as a JPEG file with quality level 75. EncParams.Param(0) = New EncoderParameter(Enc, 75L) JpgName = Path.GetFileNameWithoutExtension(File.FullName) & _ "75" & ".jpg" Image.Save(JpgName, CodecInfo, EncParams) Console.WriteLine("Saved: " & JpgName) Console.WriteLine() Next Console.ReadLine() End Sub Private Function GetEncoderInfo(ByVal mimeType As String) _ As ImageCodecInfo Dim i As Integer Dim Encoders() As ImageCodecInfo = ImageCodecInfo.GetImageEncoders() For i = 0 To Encoders.Length - 1 If Encoders(i).MimeType = mimeType Then Return Encoders(i) End If Next Return Nothing End Function End Module

The output for this console application will look like this:

Processing images in C:TempRecipe 12-11in Converting: test.bmp Saved: test25.jpg Saved: test50.jpg Saved: test75.jpg

Paint Static Content

Problem

You want to draw custom elements on a form and make sure they are not erased when the form is minimized or obscured.

Solution

Place all your drawing code in an event handler for the Control.Paint or Form.Paint events.

Discussion

When any part of a form disappears from view, Windows automatically discards all of its graphical information. When the form reappears, Windows fires the Paint event to instruct the form to redraw itself. Thus, any custom painting logic should always be coded in a Paint event handler so that the window is refreshed accurately. To make matters even easier, the Paint event always provides a PaintEventArgs parameter. This PaintEventArgs references a Graphics object that represents the drawing surface for the control or the form. You use the Graphics object's methods to draw text, shapes, or images.

Here's an example that draws a gradient rectangle on the background of the form (as shown in Figure 12-7):

Private Sub TestForm_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint Dim Rectangle As New Rectangle(5, 5, Me.ClientRectangle.Width - 10, _ Me.ClientRectangle.Height - 10) ' Draw the rectangle border. Dim DrawingPen As New Pen(Color.Blue, 2) e.Graphics.DrawRectangle(DrawingPen, Rectangle) ' Fill the rectangle with a gradient. Dim DrawingBrush As New _ System.Drawing.Drawing2D.LinearGradientBrush( _ Rectangle, Color.Blue, Color.Gold, 45) e.Graphics.FillRectangle(DrawingBrush, Rectangle) End Sub

Figure 12-7: A form with a gradient rectangle.

In this example, the drawing code uses the size of the form. Thus, it's a good idea to add an additional bit of logic to invalidate the form if the size changes, ensuring that the form will be redrawn with a gradient background that fills the form:

Private Sub Form1_Resize(ByVal sender As Object, _ ByVal e As System.EventArgs) Handles MyBase.Resize ' Indicate that the form is no longer valid, ' and Windows should trigger a repaint. Me.Invalidate() End Sub

This approach is easy to implement for static forms in which the graphical content is always the same. In this case, you can hardcode the drawing logic in the Paint event handler. It's not as easy if you are using a dynamic form, in which the graphical content changes. In that case, you will need to use form-level variables to track the drawn content so that it can be refreshed in the Paint event handler. This approach is shown in recipe 12.13.

Paint Dynamic Content

Problem

You want to draw a combination of elements on a form and track them so that they can be redrawn later.

Solution

Place all your drawing code in an event handler for the Form.Paint event. When you need to update the form, call the Form.Invalidate method.

Discussion

In many applications, drawing takes place in response to another action, such as a user clicking a button or clicking directly on the form surface. Consider the example shown in Figure 12-8, in which the user can draw a small square object anywhere on a form simply by clicking with the mouse.

Figure 12-8: Custom drawing.

In this case, you have two choices:

In most cases, the second option is the most elegant because it concentrates all your drawing logic into a single function. The following code shows how you would implement this approach.

Public Class DrawTest Inherits System.Windows.Forms.Form ' (Designer code omitted.) ' This ArrayList tracks the user-drawn shapes. Private Points As New ArrayList() Private Sub DrawTest_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint Dim DrawingPen As New Pen(Color.Blue, 2) ' Draw all the shapes that have been drawn so far. Dim Point As Rectangle For Each Point In Points e.Graphics.DrawRectangle(DrawingPen, Point) Next pnlPoints.Text = " " & Points.Count.ToString() & " Points" End Sub Private Sub DrawTest_MouseDown(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MouseEventArgs) Handles MyBase.MouseDown If e.Button = MouseButtons.Left Then ' Define the new shape. Dim Point As New Rectangle(e.X, e.Y, 20, 20) ' Store the shape for later refreshes. Points.Add(Point) ' Invalidate the portion of the form where the new shape will be. ' Windows will call your Paint event, and update only this region. Me.Invalidate(Rectangle.Inflate(Point, 3, 3)) End If End Sub End Class

When you invalidate a portion of the form, all your drawing code will execute. However, Windows will only refresh the portion of the form that you specified when calling the Invalidate method. That means that screen flicker will be kept to a minimum, but the drawing speed might decrease if the logic in your Paint event handler is very complex or time-consuming. In that case, it would be better to use separate drawing and refreshing logic, as shown here:

Public Class DrawTest Inherits System.Windows.Forms.Form ' (Designer code omitted.) ' This ArrayList tracks the user-drawn shapes. Private Points As New ArrayList() Private Sub DrawTest_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint ' Draw all the shapes that have been drawn so far. Dim Point As Rectangle For Each Point In Points DrawShape(e.Graphics, Point) Next pnlPoints.Text = " " & Points.Count.ToString() & " Points" End Sub Private Sub DrawTest_MouseDown(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MouseEventArgs) Handles MyBase.MouseDown If e.Button = MouseButtons.Left Then ' Define the new shape. Dim Point As New Rectangle(e.X, e.Y, 20, 20) ' Store the shape for later refreshes. Points.Add(Point) ' Draw the shape. ' Note that you need to explicitly create the ' GDI+ drawing surface for the form. Dim g As Graphics = Me.CreateGraphics() DrawShape(g, Point) g.Dispose() pnlPoints.Text = " " & Points.Count.ToString() & " Points" End If End Sub Private Sub DrawShape(ByVal g As Graphics, ByVal shape As Rectangle) ' Draw the actual shape using the supplied Graphics object. Dim DrawingPen As New Pen(Color.Blue, 2) g.DrawRectangle(DrawingPen, shape) End Sub End Class

Use System Colors

Problem

You want to use system-defined colors when drawing.

Solution

Use the properties of the System.Drawing.SystemColors class.

Discussion

When mixing standard Windows interface elements with your own drawing code, you need to take special care that you follow the system color scheme. Otherwise, you might end up with illegible text—or just garishly ugly windows.

Retrieving system color information is easy. You can simply use the shared properties of the System.Drawing.SystemColors class. In addition, you can use the System.Drawing.KnownColors enumeration to retrieve a list of friendly (human-readable) color names and system color names.

Here's an example that draws text on a form using the background and foreground colors of the form title bar:

Private Sub Form_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint Dim BrushText As New SolidBrush(SystemColors.ActiveCaptionText) Dim BrushBackground As New SolidBrush(SystemColors.ActiveCaption) e.Graphics.FillRectangle(BrushBackground, 5, 5, _ Me.ClientRectangle.Width - 10, Me.ClientRectangle.Height - 10) e.Graphics.DrawString("Test", New Font("Arial", 14), BrushText, 10, 10) End Sub

Improve the Rendering Quality

Problem

You want to ensure that drawn shapes are rendered in the best possible detail.

Solution

Set the SmoothingMode and TextRenderingHint properties of the Graphics object before drawing.

Discussion

The Graphics object provides two stateful properties that configure the rendering quality. SmoothingMode allows you to use automatic antialiasing, which improves the look of curves with shading. For example, if you draw a black circle on a white background, antialiasing might add a small amount of gray shading to take away the jaggedness. TextRenderingHint performs analogous control that affects text you draw using the Graphics.DrawString method.

The following code snippet draws two ellipses with two captions, using the default quality first and then a higher-quality version. Figure 12-9 shows the results.

Figure 12-9: Customizing rendering quality.

Private Sub Form_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint Dim Pen As New Pen(Color.Green, 3) Dim Font As New Font("Arial", 12, FontStyle.Bold) ' Draw using the default quality. e.Graphics.DrawEllipse(Pen, 10, 10, 200, 200) e.Graphics.DrawString("Low Quality", Font, Brushes.Black, 50, 220) ' Specify higher-quality antialiasing. e.Graphics.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias e.Graphics.TextRenderingHint = _ Drawing.Text.TextRenderingHint.ClearTypeGridFit e.Graphics.DrawEllipse(Pen, 250, 10, 200, 200) e.Graphics.DrawString("High Quality", Font, Brushes.Black, 300, 220) End Sub

  Note

By default, no aliasing is used for drawing shapes. Thus, it's often advantageous to modify the SmoothingMode property if drawing speed isn't a concern. On the other hand, the default text quality depends on system settings. The text quality setting is usually configured to use antialiasing already (or advanced optimizations for LCD screens), and thus doesn't need to be modified in your code.

Perform Hit Testing with Shapes

Problem

You want to detect if a user clicks inside a shape.

Solution

Test the point where the user clicked with the Rectangle.Contains or GraphicsPath.IsVisible method.

Discussion

If you are creating a program that has custom graphical elements the user can interact with, you need to be able to determine when the user's mouse is inside or outside a given shape. The .NET Framework provides two methods that can help with this task. The first is the Rectangle.Contains method, which takes a point and returns True if the point is inside the rectangle.

For example, you might add the following code to the drawing program demonstrated in recipe 12.13 to check if the point where the user right-clicked lies inside any of the squares on the form.

Private Sub DrawTest_MouseDown(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MouseEventArgs) Handles MyBase.MouseDown If e.Button = MouseButtons.Left Then ' (Drawing code omitted.) ElseIf e.Button = MouseButtons.Right Then ' Check if a square was clicked. Dim Point As Rectangle Dim Inside As Boolean = False For Each Point in Points If Point.Contains(e.X, e.Y) Then Inside = True End If Next If Inside Then MessageBox.Show("Point lies in a square.") Else MessageBox.Show("Point does not lie in a square.") End If End If End Sub

In many cases, you can retrieve a rectangle for another type of shape. For example, you can use Image.GetBounds to retrieve the invisible rectangle that represents the image boundaries.

The second approach is to use the GraphicsPath class in the System.Drawing.Drawing2D namespace. This approach is useful if you want to test if a point is contained inside a non-rectangular shape. The first step is to create a new GraphicsPath and add the shape (or add multiple shapes) to the GraphicsPath. Then, you can use the IsVisible method with the clicked point. For example, you can create a GraphicsPath that contains an ellipse and a square with this code:

Dim Path As New System.Drawing.Drawing2D.GraphicsPath() Private Sub Form_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Path.AddEllipse(60, 60, 100, 100) Path.AddRectangle(New Rectangle(10, 10, 50, 50)) End Sub

The painting code draws and fills the GraphicsPath:

Private Sub Form_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint e.Graphics.SmoothingMode = Drawing.Drawing2D.SmoothingMode.AntiAlias Dim Pen As New Pen(Color.Green, 4) e.Graphics.DrawPath(Pen, Path) e.Graphics.FillPath(Brushes.Yellow, Path) End Sub

Now you can see if the user clicks inside the ellipse using IsVisible:

Private Sub Form_MouseDown(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MouseEventArgs) Handles MyBase.MouseDown If Path.IsVisible(e.X, e.Y) Then MessageBox.Show("You clicked inside the GraphicsPath.") End If End Sub

Figure 12-10 shows the application in action.

Figure 12-10: Hit testing with a GraphicsPath object.

Draw Picture Thumbnails

Problem

You want to draw a scaled-down version of a larger image.

Solution

Use the Image.GetThumbnailImage method, and specify the size of thumbnail that you want.

Discussion

The Image class provides the built-in smarts for generating thumbnails through the GetThumbnailImage method. You simply need to pass the width and height of the thumbnail you want, and the Image class will create a new Image object that fits these criteria. Antialiasing is used when reducing the image to ensure the best possible image quality, although some blurriness and loss of detail is inevitable. In addition, you can supply a notification callback, allowing you to create thumbnails asynchronously.

The following code generates a 50-by-50-pixel thumbnail:

Dim Img As Image = Image.FromFile("test.jpg") Dim Thumbnail As Image = Img.GetThumbnailImage(50, 50, Nothing, Nothing)

When generating a thumbnail, it's important to ensure that the aspect ratio remains constant. For example, if you reduce a 200-by-100 picture to a 50-by-50 thumbnail, the width will be compressed to one quarter and the height will be compressed to one half, distorting the image. To ensure that the aspect ratio remains constant, you can change either the width or height to a fixed size, and then adjust the other dimension proportionately.

The following code reads a graphic from a file, creates a proportional thumbnail, and then displays it on a form:

Private Sub Form_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint ' Read the image from a file. Dim Img As Image = Image.FromFile("test.jpg") Dim ThumbnailWidth, ThumbnailHeight As Integer ' Adjust the largest dimension to a set 50 pixels. ' This ensures that a thumbnail will not be larger than 50x50 pixels. ' If you are showing multiple thumbnails, you would reserve a ' 50x50 pixel square for each one. If ThumbnailWidth > ThumbnailHeight Then ThumbnailWidth = 50 ' Scale the height proportionately. ThumbnailHeight = CInt((50 / Img.Width) * Img.Height) Else ThumbnailHeight = 50 ' Scale the width proportionately. ThumbnailWidth = CInt((50 / Img.Height) * Img.Width) End If ' Create the thumbnail. Img = Img.GetThumbnailImage(ThumbnailWidth, ThumbnailHeight, _ Nothing, Nothing) ' Display the thumbnail. e.Graphics.DrawImage(Img, 10, 10) End Sub

Use Double Buffering to Increase Redraw Speed

Problem

You want to optimize drawing for a form that is frequently refreshed and want to reduce flicker.

Solution

Render the graphics to an in-memory bitmap, and then copy the finalized bitmap to the form.

Discussion

In some applications you need to repaint a form or control frequently. This is commonly the case when performing animation. For example, you might use a timer to invalidate your form every second. Your painting code could then redraw an image at a new location, creating the illusion of motion. The problem with this approach is that every time you invalidate the form, Windows repaints the window background (clearing the form), and then runs your painting code, which draws the graphic element by element. This can cause substantial on-screen flicker.

Double buffering is a technique you can implement to reduce this flicker. With double buffering, your drawing logic writes to an in-memory bitmap, which is copied to the form at the end of the drawing operation in a single, seamless repaint operation. Flickering is reduced dramatically.

The first step when implementing double buffering is to ensure that the form background isn't repainted automatically when the form is invalidated. This automatic clearing is the most significant cause of flicker, because it replaces your image with a blank frame (if only for a fraction of a second). To prevent background painting, override the form's OnPaintBackground method so that it takes no action.

Protected Overrides Sub OnPaintBackground( _ ByVal pevent As System.Windows.Forms.PaintEventArgs) ' Do nothing. End Sub

The next step is to create the painting code. Here is an example that animates a rising and falling ball as it traces an ellipse across a form:

' Indicates whether the animation is currently being shown. Private Animating As Boolean = False ' Track the speed and position of the ball (in the Y-axis). Private BallInitialVelocity As Double Private BallPosition As Double ' Track how long the ball has been in motion. Private StartTime As DateTime Private Sub Form_Paint(ByVal sender As Object, _ ByVal e As System.Windows.Forms.PaintEventArgs) Handles MyBase.Paint ' Check if the animation is in progress. If Animating Then ' Create an in-memory bitmap with the same size as the form. Dim Drawing As New Bitmap(Me.ClientRectangle.Width, _ Me.ClientRectangle.Height, e.Graphics) ' Get the GDI+ drawing surface for the in-memory bitmap. Dim g As Graphics = Graphics.FromImage(Drawing) g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality ' Paint the background. g.FillRectangle(Brushes.Yellow, New Rectangle(New Point(0, 0), _ Me.ClientSize)) ' Calculate the new velocity and position of the ball. Dim Elapsed As Double = DateTime.Now.Subtract(StartTime).TotalSeconds Dim BallVelocity As Double = BallInitialVelocity + 50 * Elapsed BallPosition += (BallVelocity * Elapsed) / 10 ' Draw the ball. Dim Pen As New Pen(Color.Blue, 10) g.DrawEllipse(Pen, CInt(Elapsed * 100), CInt(BallPosition), 10, 10) ' Copy the final image to the form. e.Graphics.DrawImageUnscaled(Drawing, 0, 0) ' Release the GDI+ resources for the in-memory image. g.Dispose() If BallPosition > Me.ClientRectangle.Height Then ' Stop the animation. tmrInvalidate.Stop() Animating = False End If Else ' There is no animation underway. Paint the background. MyBase.OnPaintBackground(e) End If End Sub

To start the animation, the user clicks a button. The event-handling code sets the initial properties of the ball and starts a timer.

Private Sub cmdStart_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles cmdStart.Click Animating = True BallInitialVelocity = -100 BallPosition = Me.ClientRectangle.Height - 10 StartTime = DateTime.Now tmrInvalidate.Start() End Sub

The timer simply invalidates the form (in this case, every 20 milliseconds). The result is smooth, flicker-free animation.

Private Sub tmrInvalidate_Tick(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles tmrInvalidate.Tick Me.Invalidate() End Sub

Display an Irregularly Shaped Window

Problem

You want to create a non-rectangular form.

Solution

Create a new Region object that has the shape you want for the form, and assign it to the Form.Region property.

Discussion

To create a non-rectangular form, you first need to define the shape you want. The easiest approach is to use the System.Drawing.Drawing2D.GraphicsPath object, which can accommodate any combination of ellipses, rectangles, and closed curves. You can add shapes to a GraphicsPath instance using methods such as AddEllipse, AddRectangle, and AddClosedCurve. Once you are finished defining the shape you want, you can create a Region object from this GraphicsPath—just submit the GraphicsPath in the Region class constructor. Finally, you can assign the Region to the form.

In the example that follows, an irregularly shaped form (shown in Figure 12-11) is created using two curves made of multiple points, which are converted into a closed figure using the GraphicsPath.CloseAllFigures method.

Figure 12-11: A non-rectangular form.

Private Sub Form1_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Dim Path As New System.Drawing.Drawing2D.GraphicsPath() Dim PointsA() As Point = {New Point(0, 0), New Point(40, 60), _ New Point(Me.Width - 100, 10)} Path.AddCurve(PointsA) Dim PointsB() As Point = {New Point(Me.Width - 40, Me.Height - 60), _ New Point(Me.Width, Me.Height), New Point(10, Me.Height)} Path.AddCurve(PointsB) Path.CloseAllFigures() Me.Region = New Region(Path) End Sub

  Note

When creating a non-rectangular form, you may omit the title bar portion, which will make it impossible for the user to move the form. If you want to add this ability using custom code, refer to recipe 11.17.

Create an Owner Drawn Menu

Problem

You want to create a menu that includes pictures, formatted text, or colored item backgrounds.

Solution

Set the OwnerDraw property to True for each MenuItem control you want to draw, and handle the MeasureItem and DrawItem events.

Discussion

The MenuItem class that is included with the .NET Framework is fairly limited. It doesn't provide any ability to change menu font, colors, or even add the common thumbnail icon. To add any of these enhancements, you'll need to combine your menu with custom drawing logic.

Fortunately, .NET makes it easy to replace the standard drawing logic with your own custom code. All you need to do is set the OwnerDraw property for a MenuItem to True. A context menu or main menu can contain a mix of owner-drawn and ordinary menu items, but typically you will perform the drawing work for all items except the top-level headings. Regardless of whether a menu item is owner-drawn or not, it will have its standard appearance in the Visual Studio .NET design-time environment.

Once you set the OwnerDraw property to True, you must handle two MenuItem events. First you must respond to the MeasureItem event to indicate the size the menu item should have. Second you must respond to the DrawItem event to actually draw the shapes, the text, and the images on the provided GDI+ surface, which represents the menu item.

The following example shows a basic implementation of an owner-drawn menu that uses the default menu font and colors. These event handlers are used to draw three different menu items. The appearance of these menu items is very similar to the normal menu control.

Private Sub mnu_MeasureItem(ByVal sender As System.Object, _ ByVal e As System.Windows.Forms.MeasureItemEventArgs) _ Handles mnuNew.MeasureItem, mnuOpen.MeasureItem, mnuSave.MeasureItem ' Retrieve current item. Dim mnuItem As MenuItem = CType(sender, MenuItem) Dim MenuFont As New Font("Tahoma", 8) ' Measure size needed to display text. e.ItemHeight = CInt(e.Graphics.MeasureString( _ mnuItem.Text, MenuFont).Height + 5) e.ItemWidth = CInt(e.Graphics.MeasureString( _ mnuItem.Text, MenuFont).Width + 5) End Sub Private Sub mnu_DrawItem(ByVal sender As Object, _ ByVal e As System.Windows.Forms.DrawItemEventArgs) _ Handles mnuNew.DrawItem, mnuOpen.DrawItem, mnuSave.DrawItem ' Retrieve current item. Dim mnuItem As MenuItem = CType(sender, MenuItem) e.DrawBackground() ' Draw the text with the supplied colors and in the set region. e.Graphics.DrawString(mnuItem.Text, e.Font, _ New SolidBrush(e.ForeColor), e.Bounds.Left + 7, e.Bounds.Top + 3) End Sub

You can now modify this menu to add shapes, formatting, or images. The challenge when creating a custom menu is deciding how to associate the formatting-related information for the menu (such as the font size, background color, associated image, and so on) with the menu item. A good approach is to derive a custom class that includes this information by deriving from MenuItem. This class can also override the OnMeasureItem and OnDrawItem methods to perform the custom drawing logic in the class, so your form code won't need to handle these events.

The ColoredMenuItem class shown here adds three menu enhancements: support for a custom background color, support for a custom foreground color, and support for a custom font. The drawing code simply reads these values directly from the class instance.

Public Class ColoredMenuItem Inherits MenuItem Private _ForeColor As Color Private _BackColor As Color Private _Font As Font Public Property ForeColor() As Color Get Return _ForeColor End Get Set(ByVal Value As Color) _ForeColor = Value End Set End Property Public Property BackColor() As Color Get Return _BackColor End Get Set(ByVal Value As Color) _BackColor = Value End Set End Property Public Property Font() As Font Get Return _Font End Get Set(ByVal Value As Font) _Font = Value End Set End Property ' To enhance this class, you can add additional constructors that don't ' need all this information. In your drawing logic, you can then use ' default values for menu colors and the menu font if you find this ' information has not been supplied by the user. Public Sub New(ByVal text As String, ByVal foreColor As Color, _ ByVal backColor As Color, ByVal font As Font) Me.Text = text Me.ForeColor = foreColor Me.BackColor = backColor Me.Font = font ' This menu item will always be owner drawn. Me.OwnerDraw = True End Sub Protected Overrides Sub OnMeasureItem( _ ByVal e As System.Windows.Forms.MeasureItemEventArgs) ' Measure size needed to display text. e.ItemHeight = CInt(e.Graphics.MeasureString(Text, Font).Height + 5) e.ItemWidth = CInt(e.Graphics.MeasureString(Text, Font).Width + 5) End Sub Protected Overrides Sub OnDrawItem( _ ByVal e As System.Windows.Forms.DrawItemEventArgs) ' Reverse the background and foreground colors ' if the item is selected. Dim ForeBrush, BackBrush As Brush If (e.State And DrawItemState.Selected) = DrawItemState.Selected Then ForeBrush = New SolidBrush(BackColor) BackBrush = New SolidBrush(ForeColor) Else ForeBrush = New SolidBrush(ForeColor) BackBrush = New SolidBrush(BackColor) End If ' Draw the menu item background. e.Graphics.FillRectangle(BackBrush, e.Bounds) ' Draw the menu item text. e.Graphics.DrawString(Text, Font, ForeBrush, e.Bounds.Left + 7, _ e.Bounds.Top + 3) End Sub End Class

Using the ColoredMenuItem class is slightly more work than using the basic MenuItem class. The problem is that you can't design menus that use the ColoredMenuItem class with the Visual Studio .NET menu editor. Instead, you'll need to create and configure the menu programmatically through code.

Private Sub Form_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load ' Define the font for all menu items. Dim Font As New Font("Tahoma", 8, FontStyle.Bold) ' Create the menu items. Dim Blue As New ColoredMenuItem("Blue", Color.White, Color.Blue, Font) Dim Green As New ColoredMenuItem("Green", Color.White, Color.Green, Font) Dim Lavender As New ColoredMenuItem("Lavender", Color.White, _ Color.Lavender, Font) Dim Crimson As New ColoredMenuItem("Crimson", Color.White, _ Color.Crimson, Font) ' Add the items to the main menu. mnuColors.MenuItems.AddRange( _ New MenuItem() {Blue, Green, Lavender, Crimson}) End Sub

The custom-colored menu is shown in Figure 12-12.

Figure 12-12: An owner-drawn menu.

Using the approach shown in this recipe, you can create your own rich menus that mimic the new look of the menus in Office XP and Visual Studio .NET. However, this task requires a significant amount of carefully tweaked painting logic. An easier approach might be to adapt a menu component that has been developed by a third party. The online downloads for this book and the companion site at http:// www.prosetech.com provide links to free sample menu controls that are available on the Internet.

Create an Owner Drawn List Box

Problem

You want to create a list box that includes pictures, formatted text, or colored item backgrounds.

Solution

Set the list box DrawMode to OwnerDrawVariable, and handle the MeasureItem and DrawItem events.

Discussion

By implementing an owner-drawn list box, you can draw custom content in a list box item in the same way that you would draw graphics in a Paint event handler. The only challenge is deciding where to store item-specific graphics or formatting information.

To create a basic owner-drawn list box, set the ListBox.DrawMode property to OwnerDrawVariable (or you can use OwnerDrawFixed if you know that every list item will have the same height). This signals that you want to write the drawing logic for the control. Next you need to handle two events: MeasureItem, in which you specify the size of an item row, and DrawItem, in which you use the GDI+ Graphics class to output images, shapes, or text.

The following code shows the simplest possible owner-drawn list box. It uses a fixed item height (15 pixels), calls ToString on the list object and outputs the text using the current list box font. The cell is given white text if it's selected (in which case it will have a blue background). The operation of this control matches the ordinary list box behavior fairly closely.

Private Sub lstFonts_MeasureItem(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MeasureItemEventArgs) _ Handles lstFonts.MeasureItem ' Set a fixed height. e.ItemHeight = 15 End Sub Private Sub lstFonts_DrawItem(ByVal sender As Object, _ ByVal e As System.Windows.Forms.DrawItemEventArgs) Handles lstFonts.DrawItem ' Draw the background. e.DrawBackground() ' Determine the color based on whether or not the item is selected. Dim Brush As Brush If (e.State And DrawItemState.Selected) = DrawItemState.Selected Then e.DrawFocusRectangle() Brush = Brushes.White Else Brush = Brushes.Black End If ' Draw the item text. e.Graphics.DrawString(lstFonts.Items.Item(e.Index).ToString(), _ lstFonts.Font, Brush, e.Bounds.X, e.Bounds.Y) End Sub

To create a more interesting list box, you can customize these event handlers to use different colors, formatting, or images. (This approach is shown in the QuickStart samples included with Visual Studio .NET.) However, a much better approach is to let the items themselves determine their own formatting using a custom class. This class should encapsulate all the information you need to draw the object, possibly including display text, a font, an image, a foreground and background color, and so on.

The CustomListItem class shown here allows each list item to specify its own font:

Public Class CustomListItem Private _Text As String Private _Font As Font Public Property Text() As String Get Return _Text End Get Set(ByVal Value As String) _Text = Value End Set End Property Public Property Font() As Font Get Return _Font End Get Set(ByVal Value As Font) _Font = Value End Set End Property Public Sub New(ByVal text As String, ByVal font As Font) Me.Text = text Me.Font = font End Sub Public Overrides Function ToString() As String Return Text End Function End Class

Now the DrawItem event handler retrieves the custom CustomListItem instance for the row and draws the text using the specified font:

Private Sub lstFonts_DrawItem(ByVal sender As Object, _ ByVal e As System.Windows.Forms.DrawItemEventArgs) Handles lstFonts.DrawItem ' Draw the background. e.DrawBackground() ' Determine the color based on whether or not the item is selected. Dim Brush As Brush If (e.State And DrawItemState.Selected) = DrawItemState.Selected Then e.DrawFocusRectangle() Brush = Brushes.White Else Brush = Brushes.Black End If ' Get the font from the current item. Dim Font As Font Font = CType(lstFonts.Items(e.Index), CustomListItem).Font ' Draw the item text. e.Graphics.DrawString(lstFonts.Items.Item(e.Index).ToString(), _ Font, Brush, e.Bounds.X, e.Bounds.Y) End Sub

The MeasureItem event handler also needs to take the size of the font into consideration:

Private Sub lstFonts_MeasureItem(ByVal sender As Object, _ ByVal e As System.Windows.Forms.MeasureItemEventArgs) _ Handles lstFonts.MeasureItem ' Get the height from the current item's font. Dim Font As Font Font = CType(lstFonts.Items(e.Index), CustomListItem).Font e.ItemHeight = Font.Height End Sub

To test this application, you can use the technique from recipe 12.10 to create a CustomListItemFont instance for every installed font. Remember, the custom-drawn content won't appear in the Visual Studio .NET design-time environment.

Private Sub Form_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Dim FontFamilies As New System.Drawing.Text.InstalledFontCollection() Dim Family As FontFamily For Each Family In FontFamilies.Families Try Dim Font As New Font(Family.Name, 12) Dim Item As New CustomListItem(Family.Name, Font) lstFonts.Items.Add(Item) Catch ' Ignore fonts that don't support the default size. End Try Next End Sub

The resulting list box is shown in Figure 12-13.

Figure 12-13: An owner-drawn list box.

Remember, you can extend this control by creating a new list item object. For example, you could create list boxes that accommodate thumbnail images by adding a property of type Image to the CustomListItem class.

Категории