Title: Print product signs in C#
This is a program I recently wrote to print product signs for my bakery The Enchanted Oven. You can modify the code to use your product names and prices.
In the program's main form (upper left in the picture), select the items for which you want to generate product signs. Next pick a divider type: Grid (shown in the picture), Spaced Grid (a grid with a blank margin between cells), or Cut Marks (little plus signs at the grid cell corners).
Set the minimum and maximum X and Y coordinates that you want the signs to cover on the printed page in inches. Note that the program prints in landscape orientation. You can modify the code to print in portrait orientation if you like. These values indicate the left and right page margins, but they also let you position the text in the product signs vertically. To make a good sign, they should not be centered vertically.
After you've made your selections, click the Print button to display the Print Preview Dialog (lower right in the picture above). Use the dialog to view the printout's pages. If you like the result, click the Print button in the dialog's upper left corner to send the printout to the printer.
After the printout is finished, I cut the three columns apart. I then fold the strips along the horizontal lines and tape the result closed to form a triangular prism that I can use for product signs as in the picture on the right.
The program demonstrates some useful techniques including:
- Storing product data
- Checking and unchecking items in a CheckedListBox
- Handling the PrintDocument class's PrintPage event
- Handling the PrintDocument class's BeginPrint event
- Printing data across multiple pages
The following sections describe those techniques and explain other interesting pieces of code.
Storing Product Data
This example uses a list of Product objects to store the product data. The following code shows the Product class.
public class Product
{
public string Name;
public decimal Price;
public Product(string name, decimal price)
{
Name = name;
Price = price;
}
public override string ToString()
{
return Name + " (" + Price.ToString("c") + ")";
}
}
This class basically just stores a product's name and price. It has one initializing constructor to make creating objects easier.
The class also overrides its ToString method to display the product's name followed by its price in parentheses. More on that in a bit.
Initializing Products
The following code shows how the program initializes its product data.
private List<Product> Products = new List<Product>();
// Load the data.
private void Form1_Load(object sender, EventArgs e)
{
// Cake slices.
string[] cakes =
{
"Black Forest Cake",
"Strawberry Chocolate Mousse Cake",
...
"Matcha Tiramisu",
};
foreach (string cake in cakes)
{
Products.Add(new Product(cake, 5.49m));
}
// Croissants.
Products.Add(new Product("Plain Croissant", 2.49m));
Products.Add(new Product("Mini Plain Croissant", 1.49m));
...
Products.Add(new Product("Mini Pecan Croissant", 2.49m));
string[] fruits =
{
"Cherry",
"Apple",
...
"Raspberry Cream Cheese",
};
foreach (string fruit in fruits)
{
Products.Add(new Product(fruit + " Croissant", 3.99m));
}
Products.Add(new Product("Mini Fruit Croissant", 2.49m));
// Rolls.
...
Products.Add(new Product("Mini Puff Pastry", 1.99m));
clbProducts.DataSource = Products;
}
The code starts by defining a list of Product objects. The form's Load event handler adds objects to the list.
The program has a couple of categories that share the same prices, so the code makes arrays holding their names and then loops through the arrays to generate the corresponding Product objects
This code finishes by setting the clbProducts CheckedListBox control's DataSource property equal to the list. List boxes and combo boxes use the ToString methods of the objects that they contain to determine what to display. The Product class overrides its ToString method to display the product's name and price, so that's what the clbProducts control displays.
Checking and Unchecking Items
At design time I set the checked list box's CheckOnClick property to true so, if you click a row, the control checks its box. (If you don't set that property to true, then you need to select a row and then click it again to check its box.) That lets to check and uncheck list items manually. The All and None buttons let you check or uncheck all of the items at once. The following code shows how those buttons work.
private void btnAll_Click(object sender, EventArgs e)
{
CheckUncheckAll(true);
}
private void btnNone_Click(object sender, EventArgs e)
{
CheckUncheckAll(false);
}
The buttons' event handlers simply call the following CheckUncheckAll method.
private void CheckUncheckAll(bool check)
{
for (int i = 0; i < clbProducts.Items.Count; i++)
{
clbProducts.SetItemChecked(i, check);
}
}
This method loops through all of the items in the list and calls the control's SetItemChecked method for each to check or uncheck them. (You might think the control would provide a method to check or uncheck all of the items, but no, it does not. You could turn this into an extension method, or maybe two.)
When you check or uncheck an item, the following code enables the Print button if at least one item is checked.
private void clbProducts_ItemCheck(object sender, ItemCheckEventArgs e)
{
if (clbProducts.CheckedItems.Count == 1 &&
e.NewValue == CheckState.Unchecked)
{
// The collection is about to be empty.
btnPrint.Enabled = false;
}
else
{
// The collection is about to be non-empty.
btnPrint.Enabled = true;
}
}
This is another place where you might think the control could be improved. Instead of providing an ItemChecked event to tell you when an item has been checked or unchecked, the control only provides the ItemCheck event that tells you what is going to happen. You need to figure out what the consequences of that will be.
The code shown above checks the number of items currently checked. If only one item is checked and the clicked item is about to be unchecked, then there will soon be no checked items. In that case the code disables the Print button.
If there will soon be at least one checked item, the code enables the Print button.
Printing
When you click Print, the following code executes.
private void btnPrint_Click(object sender, EventArgs e)
{
ppdSigns.Document = pdocSigns;
ppdSigns.ClientSize = new Size(700, 600);
ppdSigns.ShowDialog();
}
This code sets the ppdSigns PrintPreviewDialog component's Document property equal to the pdonSigns PrintDocument object. The dialog uses the document to generate the printout and then displays it to the user.
The code sets the dialog's size to make it larger than normal and then displays it.
When the dialog needs to generate a printout, it raises the print document's events to make it produce the printout. The first event used by this example is BeginPrint. The following code shows the event handler.
private int NextProductNum = 0;
private void pdocSigns_BeginPrint(object sender,
System.Drawing.Printing.PrintEventArgs e)
{
float xmin = float.Parse(txtXmin.Text);
float xmax = float.Parse(txtXmax.Text);
float ymin = float.Parse(txtYmin.Text);
float ymax = float.Parse(txtYmax.Text);
Xmin = (int)(xmin * 100);
Xmax = (int)(xmax * 100);
Ymin = (int)(ymin * 100);
Ymax = (int)(ymax * 100);
CellWid = (int)(100 * (xmax - xmin) / 3f);
CellHgt = (int)(100 * (ymax - ymin) / 2f);
pdocSigns.DefaultPageSettings.Margins =
new System.Drawing.Printing.Margins(50, 50, 50, 50);
pdocSigns.DefaultPageSettings.Landscape = true;
NextProductNum = 0;
}
The program uses the variable NextProductNum to keep track of the next product that it should print. You could set this value to 0 in the Print button's event handler, but the dialog may actually need to generate the printout several times. It creates the printout when it first appears so it can show it to you. If you click the dialog's Print button in its upper left corner, the dialog uses the PrintDocument event again to regenerate the printout to send it to the printer. If you click the button repeatedly, the dialog generates the printout again and again.
If you reset NextProductNum the the program's btnPrint_Click event handler, then it is only reset once and not each time the print dialog needs to generate the printout.
The BeginPrint event handler solves this problem. It first parses the information that you entered on the program's main form. Notice that the code multiplies the minimum and maximum X and Y coordinates by 100. The printer measures in hundredths of inches, so that converts the inches that you enter into the printer's units.
Next the code sets the printout's page margins (again in hundredths of inches) and makes the page print in landscape mode. (I actually should make the right margin slightly wider because my printer doesn't print all the way to half an inch from the edge of the paper so the grid's right edges are cropped off.) It finishes by resetting NextProductNum to 0 so the printout starts with the first product.
When it needs to generate a printed page, the print dialog raises the print document's PrintPage event handler. This is where the drawing occurs. The following code shows the program's PrintPage event handler.
private void pdocSigns_PrintPage(object sender,
System.Drawing.Printing.PrintPageEventArgs e)
{
DrawPage(e.Graphics);
e.HasMorePages = (NextProductNum < clbProducts.CheckedItems.Count);
}
This code calls the DrawPage method, passing it the Graphics object on which to draw, to do all of the most interesting work. It then sets e.HasMorePages to tell the print dialog whether there are more product signs to print.
The following section describes the code that actually draws the product signs on the printout.
Drawing
The following DrawPage method draws a single page of product signs on the printout.
private void DrawPage(Graphics gr)
{
// Draw the products.
DrawProduct(gr, 0);
DrawProduct(gr, 1);
DrawProduct(gr, 2);
if (radGrid.Checked)
{
// Draw the grid.
int x = Xmin;
int y = Ymin;
for (int i = 0; i < 3; i++)
{
gr.DrawLine(Pens.Black, x, y, Xmax, y);
y += CellHgt;
}
for (int i = 0; i < 4; i++)
{
gr.DrawLine(Pens.Black, x, Ymin, x, Ymax);
x += CellWid;
}
}
else if (radSpacedGrid.Checked)
{
// Draw the spaced grid.
for (int r = 0; r < 2; r++)
{
int y = Ymin + r * CellHgt;
for (int c = 0; c < 3; c++)
{
int x = Xmin + c * CellWid;
Rectangle rect = new Rectangle(
x + CELL_MARGIN, y + CELL_MARGIN,
CellWid - 2 * CELL_MARGIN,
CellHgt - 2 * CELL_MARGIN);
gr.DrawRectangle(Pens.Black, rect);
}
}
}
else
{
// Draw cut marks.
const int tic = 5;
for (int i = 0; i < 3; i++)
{
float y = Ymin + i * CellHgt;
float[] xs =
{ Xmin, Xmin + CellWid, Xmin + 2 * CellWid, Xmax };
foreach (float x in xs)
{
gr.DrawLine(Pens.Black, x - tic, y, x + tic, y);
}
}
for (int i = 0; i < 4; i++)
{
float x = Xmin + i * CellWid;
float[] ys = { Ymin, Ymin + CellHgt, Ymax };
foreach (float y in ys)
{
gr.DrawLine(Pens.Black, x, y - tic, x, y + tic);
}
}
}
}
This method first calls the DrawProduct method shown shortly to draw three product signs. It passes that method the Graphics object on which to draw and the column number where it should draw each sign.
The method then draws an appropriate grid. That code isn't short, but it is straightforward so I won't describe it.
The following code shows the DrawProduct method that draws a single product sign.
private const int CELL_MARGIN = 5;
private const float NAME_SIZE = 30;
private const float PRICE_SIZE = 18;
// Draw this product.
private void DrawProduct(Graphics gr, int col_num)
{
if (NextProductNum >= clbProducts.CheckedItems.Count) return;
Product product =
clbProducts.CheckedItems[NextProductNum] as Product;
// *****************
// Draw upside down.
// *****************
// Draw the price upside down.
RectangleF rect;
rect = new RectangleF(
Xmin + col_num * CellWid,
Ymin,
CellWid, CellHgt * 0.25f);
rect.Inflate(-CELL_MARGIN, -CELL_MARGIN);
DrawText(gr, PRICE_SIZE, rect, 180,
product.Price.ToString("c"));
// Draw the name upside down.
rect = new RectangleF(
Xmin + col_num * CellWid,
Ymin + CellHgt * 0.5f,
CellWid, CellHgt * 0.5f);
rect.Inflate(-CELL_MARGIN, -CELL_MARGIN);
DrawText(gr, NAME_SIZE, rect, 180,
product.Name);
// Draw the divider upside down.
rect = new RectangleF(
Xmin + col_num * CellWid,
Ymin + CellHgt * 0.25f,
CellWid, CellHgt * 0.25f);
DrawDivider(gr, rect, 180, Properties.Resources.divider);
// *******************
// Draw right-side up.
// *******************
// Draw the name right-side up.
rect = new RectangleF(
Xmin + col_num * CellWid,
Ymin + CellHgt,
CellWid, CellHgt * 0.5f);
rect.Inflate(-CELL_MARGIN, -CELL_MARGIN);
DrawText(gr, NAME_SIZE, rect, 0,
product.Name);
// Draw the price right-side up.
rect = new RectangleF(
Xmin + col_num * CellWid,
Ymin + CellHgt + CellHgt * 0.75f,
CellWid, CellHgt * 0.25f);
rect.Inflate(-CELL_MARGIN, -CELL_MARGIN);
DrawText(gr, PRICE_SIZE, rect, 0,
product.Price.ToString("c"));
// Draw the divider right-side up.
rect = new RectangleF(
Xmin + col_num * CellWid,
Ymin + CellHgt + CellHgt * 0.5f,
CellWid, CellHgt / 4);
DrawDivider(gr, rect, 0, Properties.Resources.divider);
// Prepare to draw the next product.
NextProductNum++;
}
This is the method that draws the product signs. If first checks NextProductNum to see if we have finished printing all of the signs. If we have run out of product signs, the method just returns. The DrawPage method still draws the grid around the sign's position, so the page may contain blank product signs. We draw on those with a marker if we need extra signs in the bakery.
The code then gets the next Product object from the Products list and it starts drawing the appropriate sign.
The code follows roughly the same approach to draw the product name, product price, and a divider both right-side up and upside down. Each of those pieces uses the same approach. It calculates the rectangle that should contain the piece, shrinks the rectangle slightly if it will contain text (so the text doesn't get too close to the grid's edges), and then calls either DrawText or DrawDivider to draw the piece.
After it finishes drawing all of the sign's pieces, the method increments NextProductNum so it will draw the next product sign the next time it is called.
DrawText
The following code shows the DrawText method.
private void DrawText(Graphics gr, float font_size,
RectangleF rect, float angle, string text)
{
using (StringFormat sf = new StringFormat())
{
sf.Alignment = StringAlignment.Center;
sf.LineAlignment = StringAlignment.Center;
GraphicsState state = gr.Save();
gr.ResetTransform();
RectangleF drawing_rect = new RectangleF(
-rect.Width / 2f, -rect.Height / 2f,
rect.Width, rect.Height);
gr.RotateTransform(angle);
gr.TranslateTransform(
rect.X + rect.Width / 2f,
rect.Y + rect.Height / 2f,
MatrixOrder.Append);
// Try smaller fonts until the text fits.
int wid = (int)rect.Width;
for (float f_size = font_size; f_size > 6; f_size -= 0.5f)
{
using (Font font = new Font("Times New Roman", f_size))
{
// See if the text will fit.
SizeF size = gr.MeasureString(text, font, wid, sf);
if (size.Height <= rect.Height)
{
gr.DrawString(text, font, Brushes.Black,
drawing_rect, sf);
break;
}
}
}
gr.Restore(state);
}
}
The DrawText method draws text inside a rectangle with a given angle of rotation. For this example, the angle is either 0 (right-side up) or 180 (upside down).
The method first creates a StringFormat object and prepares it to draw the text centered in the rectangle.
The code then saves the Graphics object's state so it can restore it later and resets the object's transformations to remove any previous ones.
Next, the code creates a rectangle the same size as the target rectangle but centered at the origin. It then applies a rotation to the Graphics object so the text is drawn right-side up or upside down as desired. It then appends a translation transformation to move the result to the original rectangle where the text should appear. Now if the program draws text inside the rectangle at the origin, the text will be rotated and then translated into the desired position.
Before it draws the text, however, the code makes one adjustment. Some of the product signs have long names so their text doesn't fit within the target rectangle. To fix that, the method makes variable font_size loop from the desired font size down to 6, decreasing by 0.5 each time through the loop. Inside the loop, the code creates a font with the current size and uses the Graphics object's MeasureString method to see how large the text would be in the test font.
If the text will fit within the target rectangle, the program draws the text and breaks out of the loop. If the text doesn't fit, the loop continues to try the next smaller font size.
The method finishes by restoring the saved graphics state.
DrawDivider
The following DrawDivider method draws a divider much as the DrawText method draws text.
private void DrawDivider(Graphics gr,
RectangleF rect, float angle, Image image)
{
GraphicsState state = gr.Save();
gr.ResetTransform();
float wid = rect.Width;
float hgt = rect.Height;
if (wid / hgt > image.Width / image.Height)
{
// The rectangle is too short and wide. Make it narrower.
wid = hgt * (image.Width / image.Height);
}
else
{
// The rectangle is too tall and thin. Make it shorter.
hgt = wid / (image.Width / image.Height);
}
RectangleF dest_rect = new RectangleF(
-wid / 2f, -hgt / 2f, wid, hgt);
gr.RotateTransform(angle);
gr.TranslateTransform(
rect.X + rect.Width / 2f,
rect.Y + rect.Height / 2f,
MatrixOrder.Append);
gr.DrawImage(image, dest_rect);
gr.Restore(state);
}
Like the DrawText method, DrawDivider first saves the Graphics object's state and resets its transformations.
The method then resizes the divider so it is as large as possible but still fits within the target rectangle. To do that, it compares the target rectangle's aspect ratio (width-to-height ratio) to that of the divider image. (If you look back at the DrawProduct method, you'll see that the image is stored in the project's property Properties.Resources.divider.)
If the rectangle is too short and wide, the code makes it narrower so it has its original height but has a width that gives it the correct aspect ratio. Similarly, if the rectangle is too tall and thin, the code makes it shorter so it has the right aspect ratio.
Next the method makes a rectangle of the correct dimensions centered at the origin. It adds transformations to the Graphics object to rotate the divider and translate it so it is placed at the center of the original destination rectangle. Finally, it draws the divider image and restores the Graphics object's saved state.
Conclusion
This example prints product signs and that may be useful to you. More importantly, it demonstrates some useful printing techniques including:
- Associating a PringDocument with a PrintDialog
- Keeping track of the next item to print and using the BeginPrint event handler to restart printing from the beginning
- Resizing the print dialog
- Printing in landscape mode
- Drawing rotated text at a particular location
- Resizing text to fit a target rectangle
- Drawing rotated images as large as possible without distortion within a target rectangle
You may even be able to use the DrawText and DrawDivider methods with minimal modification.
Download the example to experiment with it and to see additional details.
|