A print job to call your own

This article was first published in FoxTalk  in October 2005. As far as I know, it is not publicly available anywhere now. It has some information of real value, not just to Fox people wrestling with print jobs, but for people in any environment seeking to do something similar -- so it is with pleasure that I reprint it here.
Please note that, in VFP 9.0 SP2, you'll have a better way to "swap out" a copy of the report at LoadReport time than is described in this article and the helpfile. In fact, the FFC library's _ReportListener class will expose methods to leverage the new feature -- making it much more fun to do. Also, note that the proposed changes to handling of DPI in the Report Engine for SP1 and discussed herein, did, in fact, take place. Use that version of the code in SP1 and SP2.

The source for this article is available in Spacefold downloads as PrintJobsSource.zip. SQL Server Reporting folks who want to do something similar should consult the Print a report from a console app sample on Got ReportViewer?  to understand how the ideas here can be applied by them.  Both VFP and RS folks will find some related thoughts here.

In a previous article about generating PDFs (PDF Power to the People, June, 2005) I said that my custom Reportlistener class used some "special magic" to save and restore printer setups. In two PROTECTED methods of the PDFListener class, LoadPrinterInfo() and UnloadPrinterInfo(), I used VFP 9.0-enhanced calls to SYS(1037,2) and SYS(1037,3), and I recommended that you be careful to call these methods in an appropriate "push" and "pop" sequence when saving and restoring VFP's printer environment.

There are other scenarios in which it's better not to manipulate VFP's printer environment excessively. Instead, you can choose to control an entire print job yourself, and send the contents of a report to this print job. In this article, I'll use all three of VFP 9.0's new SYS(1037) calls, and a few very simple Windows API calls to handle a common requirement: switching printer bins, orientation, or other printer capabilities between report pages.

Before I start, let me assure you that I am not a C++ programmer. When I say "very simple Windows API calls", I mean "very simple". To achieve this result you need to think deeply about what VFP is doing, and what you are trying to accomplish in your VFP application, not about how Windows works.

How to give the printer a piece of your mind

The sample class for this article is called PrintJobListener. Like PDFListener, it's derived from _ReportListener in the FFC with one intermediate class layer, OLEAwareReportListener.

Refer to the sidebar, PrintJobListener's pedigree in detail, for some additional information and recommendations.

To perform its work, PrintJobListener runs all reports using ListenerType 3, which tells the Report Engine to store all page images, as it would for a Preview application. The value 3 specifies no automatic display activity after the run, so you can use it for an alternative Preview application or send the images to a different target device, such as image files.

PrintJobListener allows you to assign a sequence of print setups for a report run. Before the report run begins, PrintJobListener sets up both the FRX and the Report Engine to match your print job's requirements as closely as possible, using the information in the first set of printer instructions you've designated. After the run, when the Report Engine has prepared the content, PrintJobListener creates a print job and sends the pages to the printer, inserting your printer instructions as necessary for each set of pages (Figure 1).

figure 1
Figure 1. PrintJobListener gives you fine-grained control over printer layout settings
for multiple sets of one or more pages in the same print job.

That's the big picture. How does it all happen?

Providing instructions to PrintJobListener

To send instructions of any kind to any printer, PrintJobListener must designate a way for you to specify what you want. The class exposes a public property, PrinterSetupTable, which it uses for the name of a DBF that stores multiple printer setups. You populate the table by making calls to SYS(1037,1).

Although this table is in standard FRX format, the property value defaults to "PrintSetups.DBF" and, if you don't supply an extension with your alternate table name, PrintJobListener forces the extension to DBF. I've done this to avoid anyone thinking the table is a runnable FRX, since it contains no standard FRX layout records. The only columns of significance to SYS(1037) and print jobs are Expr, Tag, and Tag2, but SYS(1037) requires all standard FRX columns to be in the table, as well as EXCLUSIVE access to the table, to work.

You designate different records in this table for retrieval by adding appropriate record identifiers in the Name column, which SYS(1037) ignores. PrintJobListener uses the memofield Name, rather than the 10-character field UniqueID, for this purpose, because these identifiers should be varied and readable.

When you're ready to run a report, you use a second public property, FRXPrintSetups, to hold a Collection object. The value for each item in the collection should be the starting page number of a group of pages. The key for each item in the collection should be a case-insensitive match for whatever you used in the Name field in your table.

PrintJobListener processes the items of the collection in strict collection order, so use the collection's Add method in an appropriate sequence. For example, the test code included in PRINTJOBLISTENER.PRG does the following:

        LOCAL m.ox, m.oy
        m.ox = CREATEOBJECT("PrintJobListener")
        m.oy = CREATEOBJECT("Collection")
        m.oy.Add(1,"FirstPage")
        m.oy.Add(2,"SecondPage")
        m.oy.Add(3,"OtherPages")
        ox.FRXPrintSetups = m.oy
        

Because Name is a memo field, you can include more information about the expected usage of each print setup, such as for which report or report groups it is suitable. For each printer setup you add, PrintJobListener applies the appropriate printer instructions, and then sends pages to the printer until it reaches the starting page for the next setup. When it reaches the final setup in its collection, it processes all pages until it reaches the OutputPageCount value it received from the Report Engine for this report run.

This is a simple-minded way of handling page ranges, to be sure. You can come up with your own method of storing and applying page range instructions, once you understand the "main event": how to store and apply the actual setups to a print job.

Ensuring the right conditions

As you might expect, PrintJobListener's LoadReport event code sets up the conditions for the run:

             PROCEDURE LoadReport()
              IF NOT THIS.StripAndSaveFRXSetup(;
                THIS.CommandClauses.FILE)
                THIS.DoMessage(;
                  FAILED_COULD_NOT_REMOVE_FRXSETUP_LOC,;
                  MB_ICONSTOP)
                RETURN .F.
              ENDIF
              IF THIS.FRXPrintSetups.Count = 0
                THIS.FRXPrintSetups.Add(1, "AllPages")
              ENDIF
              IF THIS.MultipleSetupsOnePrinter(;
                  THIS.FRXPrintSetups)
                THIS.Setup()
              ELSE
                THIS.DoMessage( ;
                  FAILED_COULD_NOT_USE_PRINTSETUPS_LOC,;
                  MB_ICONSTOP)
                RETURN .F.
              ENDIF
              RETURN DODEFAULT()
            ENDPROC
            

First, the class checks the FRX or LBX file you're processing for any printer setup instructions. If there are any, it removes them and saves them for later restoration. If the report or label is readonly and contains printer environment data, or if PrintJobListener is unable to remove printer environment details from the FRX or LBX for any other reason, it does not continue the report run.

At this stage of the report run, if you prefer, you can make a read-write copy of the report and swap in the name of your copy for the report run (there is an example showing how to do this in the LoadReport topic of the VFP docs). For PrintJobListener, I decided that if you embedded such instructions in a readonly table, you really wanted them there — and, consequently, that this report or label was not suitable for print job manipulation.

StripAndSaveFRXSetup(tFRXFile), the method handling any printer environment values in your report or label form, saves printer environment data to a protected member property. When you investigate this method, notice that StripAndSaveFRXSetup indicates a successful result whether it actually saved a setup information or not. If there is no setup information to save, there's no problem whether the report is readonly or read-write. It returns a failure result if the report contains setup information and is readonly (Figure 2).

figure 2
Figure 2. The report run cannot continue if the report contains printer setup information and is read-only.

As you can probably tell, this public method is designed for use outside report runs as well as during a LoadReport event. It has a straightforward companion method, RestoreSavedFRXSetup(tFRXFile). You can experiment with using these methods to transfer print setup information between FRXs, or between FRXs and print setup storage tables, if you like.

Next, PrintJobListener's LoadReport code checks its FRXPrintSetups collection property for appropriate contents. If you have not provided any instructions, it adds a single item to the collection to handle all the pages of your report.

As a final preliminary step, it calls its MultipleSetupsOnePrinter method to confirm availability of its printer setup table, and your instructions in the table. MultipleSetupsOnePrinter is capable of creating the table, if it does not exist. The subsidiary method performing this task uses code I recommend for any similar requirement; there is no need to maintain a "dummy" report as a model, and the result should be free of any normal FRX records:

            m.lcSafety = SET("SAFETY")
            SET SAFETY OFF
            CREATE CURSOR (THIS.FRXTempAlias) (onefield l)
            CREATE REPORT (THIS.PrinterSetupTable) ;
              FROM DBF(THIS.FRXTempAlias)
            USE IN (THIS.FRXTempAlias)
              USE (THIS.PrinterSetupTable) ;
               ALIAS (THIS.FRXPrintSourceAlias) EXCLUSIVE
              ZAP
            IF m.lcSafety = "ON"
              SET SAFETY ON
            ENDIF
        

PrintJobListener attempts to get exclusive use of the printer setup table at this point in its processing. If the table previously existed, exclusive use may not be available, so it opens the setup table shared.

With the table available, MultipleSetupsOnePrinter attempts to find your specified setup names in the Name field. If they are not available, and if it has exclusive use of the table, the method offers an opportunity for you or your user to add the new setups on-the-fly (Figure 3).

figure 3
Figure 3. You can add new setups for the same printer on-the-fly.

Here is an excerpt showing how SYS(1037,1) performs this minor miracle. This code processes all collection members after the first one in a loop, after it has handled the first item in your collection. The code handling the first item is similar:

      m.lcSetup = toSetupList.GetKey(m.liIndex)
      GO TOP
      INSERT BLANK BEFORE
      GO TOP && probably not required, doesn't hurt
      THIS.DoStatus(PICK_PRINTERSETUP2_LOC)
      SYS(1037,1)
      DO WHILE .T.
         IF THIS.GetPrinterSetupDeviceInfo(Expr) == ;
            m.lcPrinter
            REPLACE Name WITH m.lcSetup
            EXIT
         ELSE
            BLANK FIELDS Expr, Tag, Tag2
            THIS.DoMessage(PICK_SAME_PRINTER_ALL_SETUPS_LOC, ;
             MB_ICONEXCLAMATION)
            SYS(1037,1)
         ENDIF
      ENDDO
    

I particularly want you to observe my use of the INSERT BLANK BEFORE statement. Yes, I do find it incredibly amusing to use this decrepit command in a modern setting, but, honestly, INSERT BLANK BEFORE also happens to be ideal when you're coding with SYS(1037). SYS(1037) uses only the first record in a table!

Also, notice the check for the name of the printer, inside a DO WHILE loop. True to its name, MultipleSetupsOnePrinter requires that all the setups you install as part of a single collection of setups are printer setups for the same printer (Figure 4). Your multiple setups can have any other differences you like, but if they're not instructions for the same printer, it's hardly worthwhile trying to send them to the same print job.

figure 4
Figure 4. All setups for the print run must be for the same printer.

MultipleSetupsOnePrinter uses a simple parsing routine, GetPrinterSetupDeviceInfo, on the Expr field to get the name of the printer setup each time it calls SYS(1037) so it can verify the printer against the first item in the collection. You'll see this utility method in use in several PrintJobListener methods.

In the LoadReport code, you notice that MultipleSetupsOnePrinter takes the collection as an argument, rather than just referencing THIS.FRXPrintSetups directly. This method is public, and in non-demo-use, you can call it outside any report runs, with a temporary collection object or a member property of a form, if you prefer. In an application, you can provide an appropriate user interface, letting users know to what use, and for what reports, each setup will be designated. You can include associated report names or additional information as part of the Name field, or use other unused columns in the FRX for this purpose.

If MultipleSetupsOnePrinter can't get exclusive use of the table, or if the table is readonly, it can't add setups on-the-fly, but it still checks out the existence of your designated setups for this run. Note that it does not re-verify the sequence for consistent printer information; managing this table is your responsibility. The print job won't fail if you make a mistake and designate print setups that don't belong to the same printer. However, you probably won't get the results you expect.

With preliminary hurdles cleared, LoadReport calls the aptly-named Setup method to begin its real work:

    PROTECTED PROCEDURE Setup()
       THIS.Declares()
       THIS.OldPrinterName = SET("PRINTER",3)
       THIS.SetFRXDataSession()
       USE (THIS.PrinterSetupTable) IN 0 ;
         ALIAS (THIS.FRXPrintSourceAlias) AGAIN SHARED
       THIS.SetPrinterOptions(THIS.FRXPrintSetups.GetKey(1), .T.)
       SELECT 0
       THIS.ResetDataSession()
       SET PRINTER TO NAME (THIS.CurrentPrinterName)
     ENDPROC
    

First, Setup issues a series of DECLARE-DLL statements. You'll see the individual Windows API calls in use shortly; for now, I want to point out that their arguments are not complex or abstruse. Only two of them even contain a "struct" as an argument and you don't have to worry about either one.

Next, Setup saves the name of the original VFP printer, using SET("PRINTER",3). It re-opens the printer setup instruction table, SHARED this time, using the alias associated with the table for the balance of the report run (stored in its protected FRXPrintSourceAlias property). Then it calls its SetPrinterOptions method, passing the key for the first print setup in its collection. SetPrinterOptions checks the print setup table for associated instructions:

    SELECT * FROM (THIS.FRXPrintSourceAlias) WHERE ;
      ALLTR(UPPER(Name)) == ALLTR(UPPER(tcWhichSetup)) ;
      INTO CURSOR (THIS.FRXPrintTargetAlias)
    IF USED(THIS.FRXPrintTargetAlias)
      IF NOT EOF(THIS.FRXPrintTargetAlias)
         SELECT (THIS.FRXPrintTargetAlias)
         THIS.GetHDC()
         IF THIS.HDC # -1
            IF tlSaveExistingOptions
              THIS.SaveRestorePrinterSetupData(.T.)
            ENDIF
            = ResetDC(THIS.HDC,ALLTRIM(Tag2))
         ENDIF
      ENDIF
      USE IN (THIS.FRXPrintTargetAlias)
   ENDIF
    

In the slightly-excerpted version of SetPrinterOptions above, you see a call to the class's GetHDC method. This method is responsible for getting and maintaining a handle to your own (not VFP's) print job. As you see below, it simply parses the Expr field from your print setup instructions table to hand the relevant information to the Windows CreateDC function. At the same time, it provides important information to the rest of the Setup routines by storing a value in the CurrentPrinterName property:

    PROTECTED PROCEDURE GetHDC()
     IF THIS.HDC = -1
       *&* create one
       LOCAL m.lcDevice, m.lcDriver, m.liSelect
       IF USED(THIS.FRXPrintTargetAlias)
         m.liSelect = SELECT()
         SELECT (THIS.FRXPrintTargetAlias)
         m.lcDevice = THIS.GetPrinterSetupDeviceInfo(Expr,"DEVICE")
         IF EMPTY(m.lcDevice)
            m.lcDevice = SET("PRINTER",3)
         ENDIF
         THIS.CurrentPrinterName = m.lcDevice
         m.lcDriver = THIS.GetPrinterSetupDeviceInfo(Expr,"DRIVER")
         IF EMPTY(m.lcDriver)
            m.lcDriver = "winspool"
         ENDIF
         THIS.HDC = CreateDC( m.lcDriver, m.lcDevice, CHR(0), 0 )
         SELECT (m.liSelect)
       ELSE
         THIS.HDC = -1
       ENDIF
     ENDIF
   ENDPROC
    

With the handle available after its call to GetHDC, SetPrinterOptions calls Windows' ResetDC() function. It passes the handle and your setup instructions to override the user's standard preferences for this printer setup.

ResetDC takes setup instructions in the form of a struct — but don't worry. The struct you need is exactly what SYS(1037,1) beautifully and completely, though unintelligibly, saved for you in the Tag2 field of your print setups table. All you have to do is pass it in.

On this initial call, Setup also passes a second argument to SetPrinterOptions, indicating that SetPrinterOptions should save the initial state of the printer it intends to use, before it calls ResetDC to make any changes. SetPrinterOption calls SaveRestorePrinterSetupData for this task.

SaveRestorePrinterSetupData uses SYS(1037,2) on the save and SYS(1037,3), when it's called again at the end of the report run, to perform the restore. PrintJobListener has a second protected member property for this purpose, similar to the one used in StripAndSaveFRXSetup.

Having called SetPrinterOptions for the first time, and having set your designated printer for this run to the appropriate values, PrintJobListener has arranged your report layout the way you want it. As its final action, Setup synchs up the Engine to obey the page size, orientation, et cetera, in your first print setup, with the simple command SET PRINTER TO NAME (THIS.CurrentPrinterName). Remember, this code all runs during LoadReport, so the Report Engine hasn't started its own work to construct the report pages yet.

It's almost all over except the shouting. After this mass of setup code, PrintJobListener does absolutely nothing to customize the Report Engine's processing of the report run, so I'm going to move directly to UnloadReport, where you send page images to the printer, in the next section.

But first, take a moment to consider exactly what all this setup code buys you.

Review the sidebar How many *&#@#!^%?? things do I have to save and restore??.

How to print a document

Once it gets to the UnloadReport event, PrintJobListener first ascertains that there are pages to process; you might have RETURNed .F. from the LoadReport event. Assuming it has pages ready to print, PrintJobListener must first tell the printer to expect a document. It uses the Windows function StartDoc for this purpose, passing the handle it has already saved during Setup.

After iterating through your FRXPrintSetups collection to send each group of pages to the printer, of course it also tells the printer the document is complete, using the EndDoc function and passing the same handle. Finally, it has a Cleanup method in which it restores all the things it saved during its long setup process.

The code for UnloadReport, and the process of "embracing" a print run with StartDoc and EndDoc calls, is relatively straightforward, so I won't include the UnloadReport code in the article text. When you do investigate the method in the source code, you'll notice that StartDoc is the second Windows call in the set (besides ResetDC) that could potentially involve constructing a struct as an argument — but I'm simply passing a string of null characters. This argument sends the document to the device to which you've already pointed the function by handing it the device context handle.

If you want to get fancy, you can go ahead and "enrich" the StartDoc code by adding information to its lcDocInfo argument. For example, you could provide a name to show in the Windows Print Queue for your print job, using the Reportlistener PrintJobName property to get the desired value.

The truly significant activity for controlling a print job and, in my experience, the one that VFP developers have trouble understanding, is sending the individual pages in the document to the printer, using the Reportlistener's OutputPage method. Although the method name indicates that you're sending a page to the target device, remember that OutputPage only sends a "page" from the Report Engine's point of view. The printer has no way of knowing that each image you send is, in fact, a page; you have to tell it. Just as you embraced the print job with StartDoc and EndDoc calls, you have to embrace each page with StartPage and EndPage calls.

It might help some of you to recall the old FoxPro PRINTJOB… ENDPRINTJOB construct for character-based output. StartDoc() and EndDoc() perform similar functions to these output-bracketing commands. Remember the ON PAGE and EJECT PAGE commands you used in a PRINTJOB? You have to do something to let the printer know what bundles of output-producing instructions constitute a page, now, just as you did then. The fact that OutputPage wraps the ReportEngine's bundle into a single image is immaterial. Here's the code that PrintJobListener uses. UnloadReport calls this method to handle each setup-based group of pages:

    PROTECTED PROCEDURE ProcessPages(;
     tiSetupIndex, tiPageStart, tiPageEnd)
     LOCAL m.liPage, m.l, m.t, m.w, m.h
  
     THIS.SetPrinterOptions( ;
         THIS.FRXPrintSetups.GetKey(m.tiSetupIndex))
     THIS.SizePages(@m.l, @m.t, @m.w, @m.h)
     *&* passing the coords by reference
     FOR m.liPage = m.tiPageStart TO m.tiPageEnd
       = StartPage(THIS.HDC)
       THIS.OutputPage(m.liPage, THIS.HDC, ;
               LISTENER_DEVICE_TYPE_HDC, ;
               m.l, m.t, m.w ,m.h)
       = EndPage(THIS.HDC)
     ENDFOR
   ENDPROC
    

In this method, you notice a call to SetPrinterOptions before ProcessPages begins to send pages to the printer. For each print setup you designated in your collection, SetPrinterOptions invokes ResetDC with a different set of instructions on the existing handle, using the Tag2 contents of the appropriate print setup table record.

After it's adjusted the print job for the coming page range, ProcessPages needs to figure out the appropriate left, top, width, and height coordinates for OutputPages. Each print setup in your collection may have a different page size and orientation, so these values may be different for each page range. This is the second area of confusion for most VFP developers.

Part of the confusion arises because VFP 9.0 RTM uses an unexpected default value for its DPI when providing output to a device of this type. When you pass a device type of 0 (hDC or GDI handle) to the Report Engine, the Engine uses this handle to construct the GDI+ handle (type 1) it needs. Windows sets a default DPI, which turns out to be 96, in this situation, and the Report Engine simply accepts this value. Xbase code has no opportunity to give alternative instructions about device units or resolution.

In SP-1 or later versions, the Engine may be adjusted to interrogate the handle you pass to the OutputPage method, and set the DPI appropriately to the printer you're using -- which would make for much simpler calculation code for most coordinate values you would want to use as OutputPage arguments. I've included both versions of the required calculation code in PrintJobListener's SizePages method. If you are not using the RTM version and the sizing code does not appear to be working properly when you run the test code, adjust the DEFINEd constant USE_DEFAULT_DPI_GRAPHICS value you see at the top of PRINTJOBLISTENER.PRG to .F.. If you implement this technique in your applications, you can change this evaluation to a VERSION() check after testing.

Unfortunately, working in the right units is only a small part of the confusion you face when you work out the coordinates. The real question is: how should you size and scale each page?

The Report Engine calculates page dimensions exactly once per run, no matter how many print setups (each with its own page size, offsets, and orientation) you decided to create for that run. PrintJobListener adjusts the Report Engine at the beginning of the run to use your first set of instructions, in Setup code you saw earlier. The SizePages method does its best to match subsequent printer instructions as the print job progresses. It uses calls to the Windows function GetDeviceCaps to get information about the current print setup for each page group, and then determines a strategy for matching those sizes.

PrintJobListener exposes a property, ScalePages, to allow you to tune this strategy. For each report run, you can choose to scale-and-retain, scale-and-clip, or scale-and-stretch pages (Figure 5). These choices are similar to those available when you use an image control in a report layout, and, just as you've found with images in reports, each choice is valid for certain combinations of image and page-container sizes. However, these three choices are by no means the only choices you could make.

figure 5
Figure 5.You can choose from three scaling options for each report run.

For example, you could offer an option to put multiple report page images on a single physical page. In this scenario, the ProcessPage method would check the ScalePages property, of course, because it would not include StartPage and EndPage calls for each individual report page.

You could also construct a report layout for form letters, with a title or summary band holding the envelope layout and the rest of the pages holding the letter contents. In this scenario, you might want to offset the top and left positions of the envelope print setup to match special requirements. If the form letters are all one page in length, you could work out an "odd-even" system instead of FRXPrintSetups' collection of page ranges. You could also read a table containing start page, end page, and print setup name values during the report run. In some cases, you could even use the Reportlistener's TwoPassProcess property to construct the print setup collection or a table of page ranges on the fly, during the report pre-pass.

PrintJobListener's ScalePages_Assign method restricts you to three possible values for the ScalePages property, to cover the calculation CASEs I've included in the SizePages code. The CASE statement in the method contains a CASE .F. and some comments, as a placeholder for additional choices you might add.

As usual in VFP, the possibilities boggle the mind.

Move on to bigger and better things

Why stop there? All the scenarios, and the print job handling techniques as presented by PrintJobListener, provide multiple print setups for a single report, but you can do even more when you use similar code to handle report pages from multiple reports.

You already know that you can use NOPAGEEJECT to chain multiple VFP reports in a single print job. Using what you've learned in this article, you can chain multiple reports more flexibly than NOPAGEJECT allows. Simply use a separate Reportlistener, set to ListenerType 3, for each report in the run. Call their OutputPage methods, with appropriate "embracing" code, after each report finishes, rather than within the UnloadReport event, applying new print setup options as you go.

When you take this approach, you have the ability to design each FRX appropriately for the task at hand, with a different layout size. Think of each FRX layout as a document section, similar to a Word document's sections. Not coincidentally, Word allows you to associate a layout and print instructions with each section; Word's "first page — other pages" facility is merely a special case within this feature.

What will change in Sedna (a set of enhancements to VFP that is scheduled for release in 2007)? The short answer is "not a thing", in the sense that the code in PrintJobListener will still work. Maybe some of this will get a bit easier, because Sedna might provide an Xbase toolkit to manage some of the details for you. But you have all the tools you need to start evaluating your options, and implementing a strategy that will work, right now.


Sidebars