ORIGINAL DRAFT

Java 1.2 introduced a more sophisticated printing API that allows developers to implement cross-platform solutions with all the printing features expected in native applications. Today’s modern applications often provide a preview feature that lets the user view scaled page representations on-screen to ensure the output will match their expectations.

This month’s component, JPreview, provides this functionality for you a simple JPanel extension. JPreview lets you scale pages based on a percentage value, as well as by number of pages you want to see displayed on the screen, with vertical scrolling support. You can also pop-up the Page Setup and Print dialog boxes to make adjustments from the same interface.

Figure 1: JPreview with blank pages.

Figure 1: JPreview with blank pages.

You can see the basic output from of JPreview in Figure 1. In this case, the pages are scaled to 15% of their configured size. The left button allows you to scale to full width. The second button to show a single, whole page per screen. The third button pops up a small menu that lets you pick the number of elements in a grid. You would see the same results as Figure 1, for example, if you picked a 3 by 2 grid. The pulldown menu (a JComboBox) is editable and accepts percentage values. The open book icon leads to the Page Setup dialog box (Figure 2) and the printer icon leads to the Print dialog box (Figure 3).

Figure 2: Page Setup dialog box.

Figure 2: Page Setup dialog box.

The JPreview code relies on the fact that the Printable interface exposes a print method that we can call ourselves to print the output to a component instead of to the printer. We will make sure the PageFormat is taken into account, so margins and orientation (Portrait or Landscape) are properly dealt with. We’ll also be making use of the Java 2D API to scale Graphics2D output to the size of the preview page.

Figure 3: The Print dialog box.

Figure 3: The Print dialog box.

There are several classes in this month’s implementation. Since we don’t typically have room to explore all of the classes, rest assured, you can find everything online at www.java-pro.com. We’ll focus on the central pieces and leave a few classes unexplained. They should be fairly self-explanatory and easily understood by examining the code. Figure 4 shows the relationship between the classes we’ll implement.

Figure 4: Class relationships.

Figure 4: Class relationships.

The classes in Figure 4 show that the JPreview class uses the PreviewToolbar and PreviewPanel classes to show the tools at the top and pages in the center. The toolbar uses a few PreviewButton instances and the PreviewSelector class to pick the grid dimensions. The PreviewSelector extends JMenuItem and becomes part of a JPopupMenu we’ll display when the third button is pressed.

To display the pages, we’ll use a custom layout manager called PreviewLayout, which extends AbstractLayout, a class I use to implement all my custom layout managers. AbstractLayout centralizes the basic mechanics of a layout manager and allows us to focus on the minimumLayoutSize, preferredLayoutSize and layoutContainer methods to do the actual work.

The PreviewPage component renders each page and sets appropriate scaling information. We use a PreviewBorder to make pages look raised, enhancing the esthetics just enough to be visually distinct. The PreviewBorder is very simple, implementing the Swing Border interface. The isBorderOpaque method returns false because part of the upper right and lower left corners are transparent. The getBorderInsets method returns insets of 1 on the top and left and 4 on the right and bottom. The paintBorder method draws a blue outline and a couple of shadow rectangles in black on the right and bottom edges. We won’t list the code because this class is so straight forward.

Let’s take a look at the PreviewPage class in Listing 1. Most of the code in the constructor merely stores references to key variables and sets up the display parameters, such as the background color and opaqueness, along with PreviewBorder. Our last call initializes the page rendering. The render method sets the PreviewPage preferred size, based on the scale and insets. The insets account for the PreviewBorder we’re using. After calculating the preferred size, we render the page into a BufferedImage context using the Printable interface’s print method. The paintComponent method draws a white page background and the buffered image on top.

Listing 2 shows the PreviewLayout manager class. This class is a little tricky for a couple of reasons. First, it has to deal with both the notion of scaled pages and the possibility that a preset number of pages may be requested. Secondly, it sits in a JScrollPane and needs to account for frequent resizing with a fixed width an a resizable height. Because of the second criteria, this layout manager is unlikely to be as reusable as it could be. I’ve tried to keep it uncoupled, so the code would fail elegantly outside this context, but I didn’t take the time to test it thoroughly.

The minimumLayoutSize and preferredLayoutSize methods calculate the minimum and preferred sizes based on the preferred page size and the page count. We do some adjusting to make sure we don’t run into any invalid values. If the parent is contained by a JViewport, which is what happens in a JScrollPane, we use the viewport width. If the grid size is set in the PreviewPane, we use the grid width as the number of horizontal elements in our calculations. This allows us to adjust our grid width dynamically if it wasn’t explicitly set. The layoutContainer method does the actual resizing in each of the contained pages. The adjusted component bounds take the insets, vertical and horizontal gaps into account and produce the output you see in the PreviewPanel.

The PreviewPanel code in Listing 3 works with the PreviewLayout manager to arrange the PreviewPage instances appropriately. How that’s done depends on whether we are trying to accommodate a given set of grid dimensions or based only on a scale value. To keep track of which approach to use, we implement a setScale method and a setGrid method. When the grid is set, a Dimension object is associated with it and the scale is calculated from the width and height. If setScale is called, we intentionally set the grid instance variable to null to be sure this code is not executed.

The PreviewPanel constructor generates a new PreviewPage until it receives a PrinterException. This serves the dual purpose of failing elegantly and allows us to throw the exception explicitly when the NO_SUCH_PAGE value is returned by the Printable interface print method. The render method calculates the scale if the grid Dimension is set. The scale is adjusted, based on the largest dimension to display the entire grid in order to be sure the results fit within the display window. The render method in each of the PreviewPage instances is then called to resize the individual page displays. We call revalidate to be sure the layout manager has a chance to rearrange the PreviewPage components.

We won’t cover the PreviewButton implementation, which merely sets the icon to use and the base dimensions of the button. We disable focus painting and add the ActionListener passed in as the second argument in the constructor. The icons are expected to be found in a subdirectory called icons. We return true for the isFocusTraversable method and false for the isDefaultButton method.

We also won’t list the PreviewSelector class. You can think of this one as a bonus you can find online. PreviewSelector extends JMenuItem and implements a grid of page icons that allow you to select the display dimensions visually. The constructor lets you set the number of icons to display horizontally and vertically. A getGridSize method returns a Dimension object with the last selected size. The paintComponent method draws icons in white or blue, depending on whether they are selected. The remaining code handles mouse events and fires an action event when the mouse is released.

Listing 4 shows the code for PreviewToolbar, which sets up the buttons and a JComboBox, registers to receive their action events and responds to each of the possible selections made. The PreviewToolbar holds a reference to the PreviewPanel which was passed in via the constructor. This reference is used to call setScale and getGrid, based on button presses and menu selections. Some of the menu selections in the combo box map directly onto the same behavior as the buttons, so we simply reassign the source variable to trigger these actions further down in the code. The PreviewSelector response is handled by setting the grid value directly.

The JPreview code is trivial and merely sets the PreviewPanel in a JScrollPane with the vertical scroll bar enabled and the horizontal scroll bar disabled. The JScrollPane is placed in the CENTER of a BorderLayout and the PreviewToolbar is placed to the NORTH. Finally, JPreview delegates accessor methods directly to the PreviewPanel instance, implementing getScale, setScale, getGrid and setGrid methods for convenience.

There is one last, undocumented class you can find online. ExtendedInsets, as the name suggests, extends the Insets class and adds a few convenience methods that retrieve the total width and height of the horizontal and vertical edges. We also provide methods for adjusting the x, y, width and height from a component that uses insets. These are useful in accounting for insets when you work with them. The final call is adjustBounds. It returns a rectangle that accounts for the edges when passed the bounds for a given component. This is the most useful call most of the time, since is encapsulates an adjustment you would typically make before deciding where to draw in a paint method.

This implementation of JPreview provides a simple mechanism for providing a print preview facility in your programs. The principles are simple and straight forward, and the code is complete enough to make use of in your own environment. Whenever you need to support printing, you may want to consider presenting this kind of interface to the user to that they can preview the output before it gets printed. This implementation does not optimize for memory consumption, so large documents may cause problems, but extending the code to handle this should be easy enough for you to tackle. I hope this code servers you well.

Listing 1

import java.awt.*;
import java.awt.geom.*;
import java.awt.image.*;
import java.awt.print.*;
import javax.swing.*;

public class PreviewPage extends JPanel
{
  protected PrinterJob job;
  protected Printable printable;
  protected int page;
  protected BufferedImage buffer;
  
  public PreviewPage(PrinterJob job, Printable printable,
    int page) throws PrinterException
  {
    this.job = job;
    this.printable = printable;
    this.page = page;
    setOpaque(false);
    setBackground(Color.white);
    setBorder(new PreviewBorder());
    render(job.defaultPage(), 0.1);
  }
  
  public void render(PageFormat format, double scale)
    throws PrinterException
  {
    int w = (int)format.getWidth();
    int h = (int)format.getHeight();
    int ww = (int)(format.getWidth() * scale);
    int hh = (int)(format.getHeight() * scale);
    
    Insets insets = getInsets();
    setPreferredSize(new Dimension(
      ww + insets.left + insets.right,
      hh + insets.top + insets.bottom));
    
    buffer = new BufferedImage(w, h,
      BufferedImage.TYPE_INT_RGB);
    Graphics2D g = (Graphics2D)buffer.getGraphics();
    g.scale(scale, scale);
    g.setColor(Color.white);
    g.fillRect(0, 0, w, h);
    
    if (printable.print(g, format, page) ==
        Printable.NO_SUCH_PAGE)
      throw new PrinterException("No Such Page");
  }
  
  public void paintComponent(Graphics g)
  {
    Insets insets = getInsets();
    g.setColor(getBackground());
    int w = getSize().width;
    int h = getSize().height;
    g.fillRect(insets.left, insets.top,
      w - insets.left - insets.right,
      h - insets.top - insets.bottom);
    if (buffer != null)
    {
      g.drawImage(buffer, insets.left, insets.top, this);
    }
  }
}

Listing 2

import java.awt.*;
import javax.swing.*;

public class PreviewLayout extends AbstractLayout
{
  protected int hgap, vgap;
  
  public PreviewLayout()
  {
    this(4, 4);
  }

  public PreviewLayout(int hgap, int vgap)
  {
    this.hgap = hgap;
    this.vgap = vgap;
  }
  
  public Dimension minimumLayoutSize(Container parent)
  {
    int count = parent.getComponentCount();
    if (count < 1) return parent.getMinimumSize();
    Component child = parent.getComponent(0);
    int w = child.getMinimumSize().width + hgap;
    int h = child.getMinimumSize().height + vgap;
    int width = Math.min(count,
      Math.max(1, parent.getSize().width / w));
    if (parent instanceof PreviewPanel)
    {
      Dimension grid = ((PreviewPanel)parent).getGrid();
      if (grid != null) width = grid.width;
    }
    int height = (int)Math.ceil(count / width);
    if (count % width != 0) height++;
    int wide = width * w;
    if (parent.getParent() instanceof JViewport)
    {
      wide = ((JViewport)parent.getParent()).
        getExtentSize().width;
    }
    return new Dimension(wide, height * h + hgap);
  }

  public Dimension preferredLayoutSize(Container parent)
  {
    int count = parent.getComponentCount();
    if (count < 1) return parent.getPreferredSize();
    Component child = parent.getComponent(0);
    int w = child.getPreferredSize().width + hgap;
    int h = child.getPreferredSize().height + vgap;
    int width = Math.min(count,
      Math.max(1, parent.getSize().width / w));
    if (parent instanceof PreviewPanel)
    {
      Dimension grid = ((PreviewPanel)parent).getGrid();
      if (grid != null) width = grid.width;
    }
    int height = (int)Math.ceil(count / width);
    if (count % width != 0) height++;
    int wide = width * w;
    if (parent.getParent() instanceof JViewport)
    {
      wide = ((JViewport)parent.getParent()).
        getExtentSize().width;
    }
    return new Dimension(wide, height * h + hgap);
  }
  
  public void layoutContainer(Container parent)
  {
    int count = parent.getComponentCount();
    if (count < 1) return;
    Component child = parent.getComponent(0);
    int w = child.getPreferredSize().width;
    int h = child.getPreferredSize().height;
    int width = Math.max(1,
      parent.getSize().width / (w + hgap));
    int height = Math.max(1,
      parent.getSize().height / (h + vgap));
    ExtendedInsets parentInsets =
      new ExtendedInsets(parent.getInsets());
    int top = parentInsets.top;
    if (parent instanceof PreviewPanel)
    {
      Dimension grid = ((PreviewPanel)parent).getGrid();
      if (grid != null) width = grid.width;
    }
    for (int i = 0; i < count; i++)
    {
      child = parent.getComponent(i);
      int left = parentInsets.adjustX(
        (parentInsets.adjustWidth(
          parent.getSize().width - (w + hgap) * 
          Math.min(width, count))) / 2);
      int x = left + (w + hgap) * (i % width);
      int y = top + (h + vgap) * (i / width);
      child.setBounds(x, y, w, h);
    }
  }
}

Listing 3

import java.awt.*;
import java.awt.print.*;
import javax.swing.*;
import javax.swing.border.*;

public class PreviewPanel extends JPanel
{
  protected PreviewLayout layout;
  protected Dimension grid;
  protected double scale;

  public PreviewPanel(PrinterJob job, Printable printable)
  {
    setBorder(new EmptyBorder(10, 10, 10, 10));
    setLayout(layout = new PreviewLayout(8, 8));
    try
    {
      int page = 0;
      while (true)
      {
        add(new PreviewPage(job, printable, page));
        page++;
      }
    }
    catch (PrinterException e) {}
    setBackground(Color.gray);
  }
  
  public Dimension getGrid()
  {
    return grid;
  }
  
  public void setGrid(Dimension grid)
  {
    this.grid = grid;
  }
  
  public double getScale()
  {
    return scale;
  }
  
  public void setScale(double scale)
  {
    this.scale = scale;
    grid = null;
  }
  
  public void render(PageFormat format)
  {
    if (grid != null)
    {
      ExtendedInsets insets =
        new ExtendedInsets(getInsets());
      double width = insets.adjustWidth(
        getPreferredSize().width);
      double height = insets.adjustHeight(
        getParent().getSize().height);
      double w = (format.getWidth() + 12) * grid.width;
      double h = (format.getHeight() + 12) * grid.height;
      scale = width / w;
      if (scale * h > height)
        scale = height / h;
    }
    int count = getComponentCount();
    try
    {
      for (int i = 0; i < count; i++)
      {
        PreviewPage page = (PreviewPage)getComponent(i);
        page.render(format, scale);
      }
    }
    catch (PrinterException e) {}
    revalidate();
  }
}

Listing 4

import java.awt.*;
import java.awt.print.*;
import java.awt.event.*;
import javax.swing.*;

public class PreviewToolbar extends JToolBar
  implements ActionListener
{
  protected JPopupMenu popup;
  protected PreviewSelector selector;
  protected PreviewButton full, one, multi, setup, print;
  protected JComboBox percent;
  protected PrinterJob job;
  protected PageFormat format;
  protected PreviewPanel panel;
  protected double scale = 1.0;
  
  public PreviewToolbar(PrinterJob job, PreviewPanel panel)
  {
    this.job = job;
    this.panel = panel;
    format = job.defaultPage();
    setLayout(new FlowLayout(FlowLayout.LEFT));
    
    percent = new JComboBox();
    percent.setEditable(true);
    percent.addItem("200%");
    percent.addItem("150%");
    percent.addItem("100%");
    percent.addItem("75%");
    percent.addItem("50%");
    percent.addItem("25%");
    percent.addItem("20%");
    percent.addItem("15%");
    percent.addItem("10%");
    percent.addItem("Page Width");
    percent.addItem("Whole Page");
    percent.addItem("Two Pages");
    percent.setSelectedIndex(8);
    percent.setMaximumRowCount(12);
    percent.addActionListener(this);

    add(full = new PreviewButton("FullSize", this));
    add(one = new PreviewButton("OnePage", this));
    add(multi = new PreviewButton("MultiPage", this));
    add(percent);
    add(setup = new PreviewButton("PageSetup", this));
    add(print = new PreviewButton("Print", this));
    
    popup = new JPopupMenu();
    popup.add(selector = new PreviewSelector(6, 4));
    selector.addActionListener(this);
    setFloatable(false);
  }
  
  public void actionPerformed(ActionEvent event)
  {
    ExtendedInsets insets = new ExtendedInsets(panel.getInsets());
    double width = insets.adjustWidth(panel.getSize().width);
    double height = insets.adjustHeight(panel.getSize().height);
    Object source = event.getSource();
    if (source == percent)
    {
      String item = (String)percent.getSelectedItem();
      if (item.equals("Page Width"))
      {
        source = full;
      }
      if (item.equals("Whole Page"))
      {
        source = one;
      }
      if (item.equals("Two Pages"))
      {
        panel.setGrid(new Dimension(2, 1));
        panel.render(format);
      }
      if (item.endsWith("%"))
      {
        scale = (double)Integer.parseInt(
          item.substring(0, item.length() - 1)) / 100.0;
        panel.setScale(scale);
        panel.render(format);
      }
    }
    if (source == full)
    {
      double w = format.getWidth();
      scale = width / w;
      panel.setScale(scale);
      panel.render(format);
    }
    if (source == one)
    {
      panel.setGrid(new Dimension(1, 1));
      panel.render(format);
    }
    if (source == multi)
    {
      popup.show(multi, 0, multi.getSize().height);
    }
    if (source == setup)
    {
      format = job.pageDialog(format);
      panel.setScale(scale);
      panel.render(format);
    }
    if (source == print)
    {
      job.printDialog();
    }
    if (source == selector)
    {
      Dimension grid = selector.getGridSize();
      panel.setGrid(grid);
      panel.render(format);
    }
  }
}