第12章 打印与预览

「打印」绝对是个大工程,「打印预览」是个更大的工程。如果你是一位 SDK 程序员,而你分配到的工作是为公司的绘图软件写一个印前预浏系统,那么我真的替你感到忧郁。可如果你使用 MFC,情况又大不相同了。

概观

Windows 的DC观念,在程序的绘图动作与实际设备的驱动程序之间做了一道隔离,使得绘图动作完全不需修改就可以输出到不同的设备上:

即便如此,打印仍然有其琐碎的工作需要由程序员承担。举个例子,屏幕窗口有卷动杆,打印机没有,于是「分页」就成了一门学问。另外,如何中断打印?如何设计水平方向(landscape)或垂直方向(portrait)的打印输出?

landscape,风景画,代表横向打印;portrait,人物画,代表纵向打印。

如果曾经有过SDK程序经验,你一定知道,把数据输出到屏幕上和输出到打印机上几乎是相同的一件事,只要换个DC(注)就好了。MFC 甚至不要求程序员的任何动作,即自动供应打印功能和预览功能。拿前面各版本的Scribble为例,我们可曾为了输出任何东西到打印机上而特别考虑什么程序代码?完全没有!但它的确已拥有打印和预览功能,你不妨执行 Step4 的【File/Print...】以及【File/Print Preview】看看,结果如图 12-1a。

注:DC 就是Device Context,在Windows中凡绘图动作之前一定要先获得一个DC,它可能代表全屏幕,也可能代表一个窗口,或一块内存,或打印机...。DC中有许多绘图所需的元素,包括坐标系统(映射模式)、原点、绘图工具(笔、刷、颜色...)等等。它还连接到低阶的输出装置驱动程序。由于 DC,我们在程序中对屏幕作画和对打印机作画的动作才有可能完全相同。

Scribble 程序之所以不费吹灰之力即拥有打印与预览功能,是因为负责数据显示的CSribbleView::OnDraw 函数接受了一个DC参数,此DC如果是 display DC,所有的输出就往屏幕送,如果是printer DC,所有输出就往打印机送。至于OnDraw 到底收到什么样的 DC,则由Framework决定——想当然耳Framework 会依使用者的动作决定之。

MFC把整个打印机制和预览机制都埋在application framework 之中了,我们因此也有了标准的UI界面可以使用,如标准的【打印】对话框、【打印设定】对话框、【打印中】对话框等等,请看图 12-1。

我将在这一章介绍 MFC 的印表与预览机制,以及如何强化它。

图12-1a 不需考虑任何与打印相关的程序动作,Scribble 即已具备印表与预览功能(只要我们一开始在AppWizard 的步骤四对话框中选择【 Printing and Print Preview】项目)。打印出来的图形大小并不符合理想,从预览画面中就可察知。这正是本章要改善的地方。

图12-1b 标准的打印UI接口。本图是选按Scribble 的【 File/Print...】命令项之后获得的【打印】对话框。

图12-1c 你可以选按Scribble 的【 File/Print Setup...】命令项,

获得设定打印机的机会。

图12-1d 打印过程中会出现一个标准的【打印状态】对话框,

允许使用者中断打印动作。

Scribble Step5加强了印表功能以及预览功能。MFC 各现成类之中已有印表和预览机制,我要解释的是它的运作模式、执行效果、以及改善之道。图 12-2 就是 Scribble Step5的预览效果,UI 方面并没有什么新东西,主要的改善是,图形的输出大小比较能够被接受了,每一份文件并且分为两页,第一页是文件名称(文件名称),第二页才是真正的文件内容,上有一表头。

图12-2 Scribble Step5打印预览。第一页是文件名称,第二页是文件内容

打印动作的后台原理

开始介绍MFC的打印机制之前,我想,如果先让你了解打印的背后原理,可以帮助你掌握其本质。

Windows 的所有绘图指令,都集中在 GDI 模块之中,称为 GDI 绘图函数,例如:

TextOut(hPr, 50, 50, szText, strlen(szText)); // 输出一字符串
Rectangle(hPr, 10, 10, 50, 40); // 画一个四方形
Ellipse(hPr, 200, 50, 250, 80); // 画一个椭圆形
Pie(hPr, 350, 50, 400, 100, 400, 50, 400, 100);  // 画一个圆饼图
MoveTo(hPr, 50, 100); // 将画笔移动到新位置
LineTo(hPr, 400, 50); // 从前一位置画直线到新位置

图形输往何方?关键在于DC,这是任何GDI绘图函数的第一个参数,可以是GetDC或BeginPaint函数所获得的「显示屏DC」(以下是SDK程序写法):

HDC hDC;
PAINTSTRUCT ps; // paint structure
hDC = BeginPaint(hWnd, &ps);

也可以是利用 CreateDC 获得的一个「打印机DC」:

HDC hPr;
hPr=CreateDC(lpPrintDriver,lpPrintType,lpPrintPort,(LPSTR)NULL);

其中前三个参数分别是与打印机有关的信息字符串,可以从WIN.INI的【windows】section中获得,各以逗号分隔,例如:

device=HP LaserJet 4P/4MP,HPPCL5E,LPT1:

代表三项意义:

  • Print Driver = HP LaserJet 4P/4MP

  • Print Type = HPPCL5E

  • Print Port = LPT1:

SDK 程序中对于打印所需做的努力,最低限度到此为止。显然,困难度并不高,但是其中尚未参杂对打印机的控制,而那是比较麻烦的事儿。换句话说我们还得考虑「分页」的问题。以文字为例,我们必须取得一页(一张纸)的大小,以及字形的高度,从而计算扣除留白部份之后,一页可容纳几行:

TEXTMETRIC TextMetric;
int LineSpace;
int nPageSize;
int LinesPerPage;
GetTextMetrics(hPr, &TextMetric);  // 取得字形数据
LineSpace=TextMetric.tmHeight+TextMetric.tmExternalLeading;//计算字高
nPageSize = GetDeviceCaps(hPr, VERTRES);  // 取得纸张大小
LinesPerPage = nPageSize / LineSpace - 1; // 一页容纳多少行

然后再以循环将每一行文字送往打印机:

Escape(hPr, STARTDOC, 4, "PrntFile text", (LPSTR) NULL);
CurrentLine = 1;
for (...) {
    ...//取得一行文字,放在char pLine[128]中,长度为LineLength。
    TextOut(hPr, 0, CurrentLine*LineSpace, (LPSTR)pLine, LineLength);
    if (++CurrentLine > LinesPerPage ) {
        CurrentLine = 1;   // 重设行号
        IOStatus = Escape(hPr, NEWFRAME, 0, 0L, 0L);  // 换页
        if (IOStatus < 0 || bAbort)
        break;
    }
}
if (IOStatus >= 0 && !bAbort) {
    Escape(hPr, NEWFRAME, 0, 0L, 0L);
    Escape(hPr, ENDDOC, 0, 0L, 0L);
}

其中的Escape用来传送命令给打印机(打印机命令一般称为escape code),它是一个Windows API 函数。

打印过程中我们还应该提供一个中断机制给使用者。Modeless 对话框可以完成此一使命,我们可以让它出现在打印过程之中。这个对话框应该在打印程序开始之前先做起来,外形类似图 12-1d:

HWND hPrintingDlgWnd;  // 这就是【Printing】对话框
FARPROC lpPrintingDlg; //【Printing】对话框的窗口函数
lpPrintingDlg=MakeProcInstance(PrintingDlg, hInst);
hPrintingDlgWnd=CreateDialog(hInst,"PrintingDlg",hWnd,lpPrintingDlg);
ShowWindow (hPrintingDlgWnd, SW_NORMAL);

负责此一中断机制的对话框函数很简单,只检查【OK】钮有没有被按下,并据以改变bAbort 的值:

int FAR PASCAL PrintingDlg(HWND hDlg,unsigned msg,WORD wParam,LONG lParam)
{
    switch(msg) {
        case WM_COMMAND:
            return (bAbort = TRUE);
        case WM_INITDIALOG:
            SetFocus(GetDlgItem(hDlg, IDCANCEL));
            SetDlgItemText(hDlg, IDC_FILENAME, FileName);
            return (TRUE);
        }
    return (FALSE);
}

从应用程序的眼光来看,这样就差不多了。然而数据真正送到打印机上,还有一大段曲折过程。每一个送往打印机 DC 的绘图动作,其实都只被记录为 metafile(注)储存在你的TEMP目录中。当你调用Escape(hPr, NEWFRAME, ...),打印机驱动程序(.DRV)会把这些metafile转换为打印机语言(control sequence 或Postscript),然后通知GDI模组,由GDI把它储存为 ~SPL 文件,也放在TEMP 目录中,并删除对应之metafile。之后,GDI模块再送出消息给打印管理器 Print Manager,由后者调用OpenComm、WriteComm 等低阶通讯函数(也都是 Windows API 函数),把打印机命令传给打印机。整个流程请参考图 12-3。

注:metafile也是一种图形记录规格,但它记录的是绘图动作,不像 bitmap 记录的是真正的图形数据。所以播放metafile比播放bitmap 慢,因为多了一层绘图函数解读动作;但它的大小比 bitmap 小很多。用在有许多四形、圆形、工程几何图形上最为方便。

这个曲折过程之中就产生了一个问题。~SPL 这种文件很大,如果你的TEMP 目录空间不够充裕,怎么办?如果Printer Manager把积存的~SPL内容消化掉后能够空出足够磁碟空间的话,那么GDI模块就可以下命令(送消息)给 Printer Manager,先把积存的~SPL 文件处理掉。问题是,在Windows 3.x之中,我们的程序此刻正忙着做绘图动作,GDI 没有机会送消息给 Printer Manager(因为Windows 3.x是个非强制性多任务系统)。解决方法是你先准备一个 callback 函数,名称随你取,通常名为AbortProc:

FARPROC lpAbortProc;
lpAbortProc = MakeProcInstance(AbortProc, hInst);
Escape(hPr, SETABORTPROC, NULL, (LPSTR)(long)lpAbortProc, (LPSTR)NULL);

GDI模块在执行Escape(hPr, NEWFRAME...) 的过程中会持续调用这个 callback 函数,想办法让你的程序释放出控制权:

int FAR PASCAL AbortProc(hDC hPr, int Code)
{
    MSG msg;
    while (!bAbort && PeekMessage(&msg, NULL, NULL, NULL, TRUE))
    if (!IsDialogMessage(hAbortDlgWnd, &msg)) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    return (!bAbort);
}

你可以从 VC++ 4.0 所附的这个范例程序获得有关打印的极佳实例:

\MSDEV\SAMPLES\SDK\WIN32\PRINTER

也可以在Charles Petzold所著的Programming Windows 3.1第15章,或是其新版Programming Windows 95第15章,获得更深入的数据。

图12-3 Windows 程序的打印机输出动作详解

以下就是SDK 程序中有关打印程序的一个实际片段。

上述各个Escape调用,是在Windows 3.0 下的传统作法,在Windows 3.1以及 Win32 之中有对应的 API 函数如下:

MFC 预设的打印机制

好啦,关于打印,其实有许多一成不变的动作!为什么开发工具不帮我们做掉呢?好比说,从WIN.INI中取得目前打印机的数据然后利用CreateDC取得打印机DC,又好比说设计标准的【打印中】对话框,以及标准的打印中断函数 AbortProc。

事实上MFC的确已经帮我们做掉了一大部份的工作。MFC已内含打印机制,那么将Framework 整个纳入EXE文件中的你当然也就不费吹灰之力得到了印表功能。只要OnDraw 函数设计好了,不但可以在屏幕上显示数据,也可以在打印机上显示数据。有什么是我们要负担的?没有了!Framework 传给OnDraw一个 DC,视情况的不同这个DC可能是显示屏DC,也可能是打印机DC,而你知道,Windows 程序中的图形输出对象完全取决于DC:

  • 当你改变窗口大小,产生WM_PAINT,OnDraw 会收到一个「显示屏DC」。

  • 当你选按【File/Print...】,OnDraw 会收到一个「打印机DC」。

数章之前讨论 CView 时我曾经提过,OnDraw是CView类中最重要的成员函数,所有的绘图动作都应该放在其中。请注意,OnDraw 接受一个「CDC 对象指针」做为它的参数。当窗口接受 WM_PAINT 消息,Framework 就调用OnDraw 并把一个「显示屏DC」传过去,于是OnDraw输出到屏幕上。

Windows 的图形装置接口(GDI)完全与硬件无关,相同的绘图动作如果送到「显示屏DC」,就是在屏幕上绘图,如果送到「打印机DC」,就是在打印机上绘图。这个道理很容易就解释了为什么您的程序代码没有任何特殊动作却具备印表功能:当使用者按下【File/Print】,application framework送给OnDraw的是一个「打印机 DC」而不再是「显示幕DC」。

在 MFC 应用程序中,View 和 application framework 分工合力完成印表工作。Application framework 的责任是:

  • 显示【Print】对话框,如图12-1b。

  • 为打印机产生一个 CDC 对象。

  • 调用 CDC对象的StartDoc和EndDoc两函数。

  • 持续不断地调用CDC对象的StartPage,通知View应该输出哪一页;一页打印完毕则调用CDC对象的EndPage。

我们(程序员)在View对象上的责任是:

  • 通知application framework总共有多少页要打印。

  • application framework 要求打印某特定页时,我们必须将Document中对应的部份输出到打印机上。

  • 配置或释放任何GDI资源,包括笔、刷、字形...等等。

  • 如果需要,送出任何escape 代码改变打印机状态,例如走纸、改变打印方向等等。

送出escape 代码的方式是,调用CDC对象的Escape 函数。

现在让我们看看这两组工作如何交叉在一起。为实现上述各项交互动作,CView 定义了几个相关的成员函数,当你在 AppWizard 中选择【Printing and Print Preview】选项之后,除了 OnDraw,你的 View 类内还被加入了三个虚函数空壳:

// in SCRIBBLEVIEW.H
class CScribbleView : public CScrollView
{
    ...
protected:
    virtual BOOL OnPreparePrinting(CPrintInfo* pInfo);
    virtual void OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo);
    virtual void OnEndPrinting(CDC* pDC, CPrintInfo* pInfo);
    ...
};
// in SCRIBBLEVIEW.CPP
BOOL CScribbleView::OnPreparePrinting(CPrintInfo* pInfo)
{
    // default preparation
    return DoPreparePrinting(pInfo);
}
void CScribbleView::OnBeginPrinting(CDC* /*pDC*/,CPrintInfo* /*pInfo*/)
{
    // TODO: add extra initialization before printing
}
void CScribbleView::OnEndPrinting(CDC* /*pDC*/, CPrintInfo* /*pInfo*/)
{
    // TODO: add cleanup after printing
}

改写这些函数有助于我们在framework的打印机制与应用程序的View对象之间架起沟通桥梁。

为了了解MFC中的打印机制,我又动用了我的法宝:Visual C++ Debugger。我发现,AppWizard 为我的View做出这样的Message Map:

BEGIN_MESSAGE_MAP(CScribbleView, CScrollView)
...
// Standard printing commands
ON_COMMAND(ID_FILE_PRINT, CView::OnFilePrint)
ON_COMMAND(ID_FILE_PRINT_PREVIEW, CView::OnFilePrintPreview)
END_MESSAGE_MAP()

显然,当【File/Print...】被按下,命令消息将流往CView::OnFilePrint 去处理,于是我以Debugger进入该位置并且一步一步执行,得到图12-4的结果。

图12-4 CView::OnFilePrint 原始代码,这是打印命令的第一战场。

标出号代码的是重要动作,稍后将有补充说明。

以下是CView::OnFilePrint函数之中重要动作的说明。你可以将这份说明与上一节「列印动作的背景原理」做一比对,就能够明白MFC在什么地方为我们做了什么事情,也才因此能够体会,究竟我们该在什么地方改写虚函数,放入我们自己的补强程序代码。

①OnFilePrint首先在堆栈中产生一个CPrintInfo对象,并构造之,使其部份成员变量拥有初值。CPrintInfo 是一个用来记录打印机数据的结构,其构造函数配置了一个Win32 通用打印对话框(common print dialog)并将它指定给 m_pPD:

// in AFXEXT.H
struct CPrintInfo // Printing information structure
{
    CPrintDialog* m_pPD; // pointer to print dialog
    BOOL m_bPreview; // TRUE if in preview mode
    BOOL m_bDirect; // TRUE if bypassing Print Dialog
    ...
};

上述的成员变量m_bPreview 如果是TRUE,表示处于预览模式,FALSE 表示处于打印模式;成员变量m_bDirect 如果是TRUE,表示省略【打印】对话框,FALSE 表示需显示【打印】对话框。

上面出现过的CPrintDialog,用来更贴近描述打印对话框:

class CPrintDialog : public CCommonDialog
{
public:
    PRINTDLG& m_pd;
    BOOL GetDefaults();
    LPDEVMODE GetDevMode() const;   // return DEVMODE
    CString GetDriverName() const;  // return driver name
    CString GetDeviceName() const;  // return device name
    CString GetPortName() const; // return output port name
    HDC GetPrinterDC() const; // return HDC (caller must delete)
    HDC CreatePrinterDC();
    ...
};

②如果必要(从命令列参数中得知要直接打印某个文件到打印机上),利用::CreateDC产生一个「打印机DC」,并做打印动作。注意,printInfo.m_bDirect 被设为TRUE,表示跳过打印对话框,直接打印。

③OnPreparePrinting是一个虚函数,所以如果CView的派生类改写了它,控制权就移转到派生类手中。本例将移转到CScribbleView手中。CScribbleView::OnPreparePrinting 的预设内容(AppWizard自动为我们产生)是调用DoPreparePrinting,它并不是虚函数,而是CView的一个辅助函数。以下是其调用堆迭,直至【打印】对话框出现为止。

CView::DoPreparePrinting 将贮存在CPrintInfo结构中的对话框 CPrintDialog* m_pPD显示出来,借此收集使用者对打印机的各种设定,然后产生一个「打印机 DC」,储存在printinfo.m_pPD->m_pd.hDC 之中。

④如果使用者在【打印】对话框中选按【打印到文件】,则再显示一个【Print to File】对话框,让使用者设定文件名。

⑤接下来取文件名称和输出设备的名称(可能是打印机也可能是个文件),并产生一个DOCINFO结构,设定其中的 lpszDocName 和lpszOutput字段。此一 DOCINFO 结构将在稍后的StartDoc 动作中用到。

⑥如果使用者在【打印】对话框中按下【确定】钮,OnFilePrint 就在堆栈中制造出一个CDC对象,并把前面所完成的「打印机 DC」附着到CDC对象上:

CDC dcPrint;
dcPrint.Attach(printInfo.m_pPD->m_pd.hDC);
dcPrint.m_bPrinting = TRUE;

⑦一旦CDC完成,OnFilePrint把CDC对象以及前面的CPrintInfo 对象传给OnBeginPrinting 作为参数。OnBeginPrinting 是CView的一个虚函数,原本什么也没做。你可以改写它,设定打印前的任何初始状态。

⑧设定AbortProc。这应该是一个callback 函数,MFC有一个预设的简易函数_AfxAbortProc 可兹利用。

⑨把父窗口除能,产生【打印状态】对话框,根据文件名称以及输出设备名称,设定对话框内容,并显示之:

AfxGetMainWnd()->EnableWindow(FALSE);
CPrintingDialog dlgPrintStatus(this);
... // 设定对话框内容
dlgPrintStatus.ShowWindow(SW_SHOW);
dlgPrintStatus.UpdateWindow();

➓StartDoc通知打印机开始崭新的打印工作。这个函数其实就是启动Windows 打印引擎。

➀ 以 for循环针对文件中的每一页开始做打印动作。

➁ 调用CView::OnPrepareDC。此函数什么也没做。如果你要在每页前面加表头,就请改写这个虚函数。

➂ 修改【打印状态】对话框中的页次。

➃ StartPage开始新的一页。

➄ 调用CView::OnPrint,它的内部只有一个动作:调用OnDraw。我们应该在CScribbleView中改写OnDraw以绘出自己的图形。

➅ 一页结束,调用dcPrint.EndPage

➆ 文件结束,调用 EndDoc

➇ 整个打印工作结束。如果有些什么绘图资源需要释放,你应该改写 OnEndPrinting函数并在其中释放之。

➈ 去除【打印状态】对话框。

➉ 将「打印机 DC」解除附着,CPrintInfo 的析构函数会把DC还给Windows。从上面这些分析中归纳出来的结论是,一共有六个虚函数可以改写,请看图 12-5。

图12-5 MFC打印流程与我们的着力点

以下是图12-5的补充说明。

  • 当使用者按下【 File/Print 】命令项 , Application Framework首先调用CMyView::OnPreparePrinting。这个函数接受一个CPrintInfo 指针做为参数,允许使用者设定Document 的打印长度(从第几页到第几页)。预设页代码是1至0xFFFF, 程序员应该在OnPreparePrinting中 呼 叫SetMaxPage 预 设 页数 。SetMaxPage 之后,程序应该调用CView::DoPreparePrinting,它会显示【打印】对话框,并产生一个打印机DC。当对话框结束,CPrintInfo 也从中获得了使用者设定的各个印表项目(例如从第n1页印到第n2页)。

Framework 如何得知使用者对于打印状态的设定?CPrintInfo 有五个函数可用,下一节有更详细的说明。

  • 针对每一页,Framework 会调用CMyView::OnPrepareDC,这函数在前一章介绍CScrollView 时也曾提过,当时是因为我们使用卷动窗口,而由于卷动的关系,绘图之前必须先设定DC的映射模式和原点等性质。这次稍有不同的是,它收到打印机DC做为第一参数,CPrintInfo 对象做为第二参数。我们改写这个函数,使它依目前的页代码来调整DC,例如改变打印原点和截割局部以保证印出来的Document内容的合适性等等。

  • 稍早我一再强调所有绘图动作都应该集中在OnDraw 函数中,Framework 会自动调用它。更精确地说,Framework 其实是先调用OnPrint,传两个参数进去,第一参数是个DC,第二参数是个CPrintInfo指针。OnPrint内部再调用OnDraw,这次只传DC过去,做为唯一参数:

// in VIEWCORE.CPP
void CView::OnPrint(CDC* pDC, CPrintInfo*)
{
    ASSERT_VALID(pDC);
    // Override and set printing variables based on page number
    OnDraw(pDC);     // Call Draw
}

有了这样的差异,我们可以这么区分这两个函数的功能:

  • OnPrint:负责「只在印表时才做(屏幕显示时不做)」的动作。例如印出

表头和页尾。

  • OnDraw :共通性绘图动作(包括输出到屏幕或打印机上)都在此完成。

看看另一个函数OnPaint:

// in VIEWCORE.CPP
void CView::OnPaint()
{
    // standard paint routine
    CPaintDC dc(this);
    OnPrepareDC(&dc);
    OnDraw(&dc);
}

你会发现原来它们是这么分工的:

所谓「显示」是指输出到屏幕上,「打印」是指输出到打印机上。

由同一函数完成显示(display)与打印(print)动作,才能够达到「所见即所得」(What You See Is What You Get,WYSIWYG)的目的。如果你不需要一个 WYSIWYG 程序,可以改写OnPrint使它不要调用OnDraw,而调用另一个绘图例程。

不要认为什么情况下都需要WYSIWYG。一个文字编辑器可能使用粗体字打印但使用控制代码在屏幕上代表这粗体字。

Scribble 打印机制的补强

MFC 预设的打印机制够聪敏了,但还没有聪敏到解决所有的问题。这些问题包括:

  • 打印出来的影像可能不是你要的大小

  • 不会分页

  • 没有表头(header)

  • 没有页尾(footer)

毕竟屏幕输出和打印机输出到底还是有着重大的差异。窗口有卷动杆而打印机没有,这伴随而来的就是必须计算Document的大小和纸张的大小,以解决分页的问题;此外,我们必须想想,在MFC预设的打印机制中,改写哪一个地方,才能让我们有办法在Document 的输出页加上表头或页尾。

打印机的页和文件的页

首先,我们必须区分「页」对于 Document 和对于打印机的不同意义。从打印机观点来看,一页就是一张纸,然而一张纸并不一定容纳 Document 的一页。例如你想印一些通讯数据,这些数据可能是要被折迭起来的,因此一张纸印的是 Document 的第一页和最后一页(亲爱的朋友,想想你每天看的报纸)。又例如印一个巨大的电子表格,它可能是Document 上的一页,却占据两张 A4 纸。

MFC这个Application Framework 把关于打印的大部份信息都记录在 CPrintInfo 中,其中数笔数据与分页有密切关系。下表是取得分页数据的相关成员,其中只有SetMaxPage和m_nCurPage 和m_nNumPreviewPages在 Scribble 程序中会用到,原因是Scribble程序对许多问题做了简化。

CPrintInfo成员名称     参考到的打印页
GetMinPage/SetMinPage   Document 中的第一页
GetMaxPage/SetMaxPage   Document 中的最后一页
GetFromPage         将被印出的第一页(出现在【打印】对话框,图12-1b)
GetToPage           将被印出的最后一页(出现在【打印】对话框)
m_nCurPage          目前正被印出的一页(出现在【打印状态】对话框)
m_nNumPreviewPages      预览窗口中的页数(稍后将讨论之)

注:页代码从 1(而不是 0)开始。

CPrintInfo 结构中记录的「页」数,指的是打印机的页数;Framework 针对每一「页」调用OnPrepareDC以及OnPrint 时,所指的「页」也是打印机的页。当你改写OnPreparePrinting时指定Document的长度,所用的单位也是打印机的「页」。如果Document的一页恰等于打印机的一页(一张纸),事情就单纯了;如果不是,你必须在两者之间做转换。

Scribble Step5 设定让每一份 Document 使用打印机的两页。第一页只是单纯印出文件名称(文件名称),第二页才是文件内容。假设我利用 View 窗口卷动杆在整个Document四周画一四方圈的话,我希望这一四方圈落入第二页(第二张纸)中。当然,边界留白必须考虑在内,如图 12-6。除此之外,我希望第二页(文件内容)最顶端留一点空间,做为表头。本例在表头中放的是文件名称。

图12-6 Scribble Step5 的每一份文件打印时有两页,

第一页是文件名称,第二页是文件内容,最顶端留有一个表头。

配置 GDI 绘图工具

绘图难免需要各式各样的笔、刷、颜色、字形、工具。这些 GDI 资源都会占用内存,而且是GDI模块的heap。虽说Windows 95 对于USER 模块和GDI模块的heap已有大幅改善,使用 32 位 heap,不再局限 64KB,但我们当然仍然不希望看到浪费的情况发生,因此最好的方式就是在打印之前配置这些 GDI 绘图对象,并在打印后立刻释放。

看看图 12-5,配置GDI对象的最理想时机显然是OnBeginPrinting,两个理由:

1. 每当Framework 开始一份新的打印工作,它就会调用此函数一次,因此不同打印工作所需的不同工具可在此有个替换。

2. 此函数的参数是一个和「打印机DC」有附着关系的CDC 对象指针,我们直接从此一CDC对象中配置绘图工具即可。

配置得来的GDI对象可以储存在View的成员变量中,供整个打印过程使用。使用时机当然是OnPrint。如果你必须对不同的打印页使用不同的GDI对象,CPrintInfo中的m_nCurPage 可以帮你做出正确的决定。

释放GDI对象的最理想时机当然是在OnEndPrinting,这是每当一份打印工作结束后,Application Framework 会调用的函数。

Scribble 没有使用什么特殊的绘图工具,因此下面这两个虚函数也就没有修改,完全保留AppWizard当初给我们的样子:

尺寸与方向:关于映射模式(坐标系统)

回忆所谓的坐标系统,我已经在上一章描述过CScrollView如何为了卷动效果而改变座标系统的原点。除了改变原点,我们甚至可以改变坐标系统的单位长度,乃至于改变座标系统的横纵比例(scale)。这些就是这一节要讨论的重点。

Document 有大小可言吗?有的,在打印过程中,为了计算Document 对应到打印机的页数,我们需要Document 的尺寸。CScribbleDoc 的成员变量 m_sizeDoc,就是用来记录Document 的大小。它是一个CSize 对象:

事实上,所谓「逻辑坐标」原本是没有大小的,如果我们说一份Document宽 800 高900,那么若逻辑坐标的单位是英吋,这就是8英吋宽9 英吋高;若逻辑坐标的单位是公分,这就是8 公分宽9 公分高。如果逻辑单位是图素(Pixel)呢?那就是 800 个图素宽 900 个图素高。图素的大小随着输出装置而改变,在 14 吋 Super VGA(1024x768)显示器上,800x900 个图素大约是 21.1 公分宽 23.6 公分高,而在一部 300 DPI(Dot Per Inch,每英吋点数)的激光打印机上,将是2-2/3 英吋宽3英吋高。

预设情况下GDI绘图函数使用MM_TEXT 映射模式(Mapping Mode,也就是坐标系统,注),于是逻辑坐标等于装置坐标,也就是说一个逻辑单位是一个图素。如果不重新设定映射模式,可以想见屏幕上的图形一放到300 DPI 打印机上都嫌太小。

解决的方法很简单:设定一种与真实世界相符的逻辑坐标系统。Windows 提供的八种映像模式中有七种是所谓的metric 映射模式,它们的逻辑单位都建立在公分或英吋的基础上,这正是我们所要的。如果把 OnDraw 内的绘图动作都设定在 MM_LOENGLISH 映射模式上(每单位 0.01 英吋),那么不论输出到屏幕上或到打印机上都获得相同的尺度。真正要为「多少图点才能画出一英吋长」伤脑筋的是装置驱动程序,不是我们。

注:GDI 的八种映射模式及其意义如下:

  • MM_TEXT:以图素(pixel)为单位,Y 轴向下为正,X 轴向右为正。

  • MM_LOMETRIC:以0.1 公分为单位,Y 轴向上为正,X 轴向右为正。

  • MM_HIMETRIC:以0.01 公分为单位,Y 轴向上为正,X 轴向右为正。

  • MM_LOENGLISH:以0.01 英吋为单位,Y 轴向上为正,X 轴向右为正。

  • MM_HIENGLISH:以0.001 英吋为单位,Y 轴向上为正,X 轴向右为正。

  • MM_TWIPS:以1/1440 英吋为单位,Y 轴向上为正,X 轴向右为正。

  • MM_ISOTROPIC:单位长度可任意设定,Y 轴向上为正,X 轴向右为正。

  • MM_ANISOTROPIC:单位长度可任意设定,且X 轴单位长可以不同于Y 轴单位长(因此圆可能变形)。Y 轴向上为正,X 轴向右为正。

回忆上一章为了卷动窗口,曾有这样的动作:

void CScribbleView::OnInitialUpdate()
{
    SetScrollSizes(MM_TEXT, GetDocument()->GetDocSize());
    CScrollView::OnInitialUpdate();
}

映射模式可以在 SetScrollSizes 的第一个参数指定。现在我们把它改为:

void CScribbleView::OnInitialUpdate()
{
    SetScrollSizes(MM_LOENGLISH, GetDocument()->GetDocSize());
    CScrollView::OnInitialUpdate();
}

注意,OnInitialUpdate 更在OnDraw之前被调用,也就是说我们在真正绘图动作OnDraw之前完成了映射模式的设定。

映射模式不仅影响逻辑单位的尺寸,也影响Y轴坐标方向。MM_TEXT是Y轴向下,MM_LOENGLISH(以及其它任何映射模式)是 Y 轴向上。但,虽然有此差异,我们的Step5 程序代码却不需为此再做更动,因为DPtoLP已经完成了这个转换。别忘了,鼠标左键传来的点坐标是先经过DPtoLP 才储存到CStroke对象并且然后才由LineTo 画出的。

然而,程序的某些部份还是受到了Y轴方向改变的冲击。映射模式只会改变 GDI各相关函数,不使用DC的地方,就不受映射模式的影响,例如CRect的成员函数就不知晓所谓的映射模式。于是,本例中凡使用到 CRect 的地方,要特别注意做些调整:

1. 修正「线条外围四方形」的计算方式。原计算方式是在 FinishStroke 中这么做:

for (int i=1; i < m_pointArray.GetSize(); i++)
{
    pt = m_pointArray[i];
    m_rectBounding.left   = min(m_rectBounding.left, pt.x);
    m_rectBounding.right  = max(m_rectBounding.right, pt.x);
    m_rectBounding.top    = min(m_rectBounding.top, pt.y);
    m_rectBounding.bottom = max(m_rectBounding.bottom, pt.y);
}
m_rectBounding.InflateRect(CSize(m_nPenWidth, m_nPenWidth));

新的计算方式是:

for (int i=1; i < m_pointArray.GetSize(); i++)
{
    pt = m_pointArray[i];
    m_rectBounding.left = min(m_rectBounding.left, pt.x);
    m_rectBounding.right = max(m_rectBounding.right, pt.x);
    m_rectBounding.top = max(m_rectBounding.top, pt.y);
    m_rectBounding.bottom = min(m_rectBounding.bottom, pt.y);
}
m_rectBounding.InflateRect(CSize(m_nPenWidth, -(int)m_nPenWidth));

这是因为在 Y 轴向下的系统中,四方形的最顶点位置应该是找Y坐标最小者;而在Y 轴向上的系统中,四方形的最顶点位置应该是找Y坐标最大者;同理,对于四方形的最底点亦然。

2. 我们在OnDraw中曾经以IntersectRect计算两个四方形是否有交集。这个函数也是CRect成员函数,它假设:一个四方形的底坐标 Y 值必然大于顶坐标的Y 值(这是从装置坐标,也就是MM_TEXT,的眼光来看);如果事非如此,它根本不可能找出两个四方形的交集。因此我们必须在 OnDraw 中做以下修改,把逻辑坐标改为装置坐标:

分页

Scribble 程序的Document 大小固定是800x900,而且我们让它填满打印机的一页。因此Scribble 并没有「将 Document分段打印」这种困扰。如果真要分段打印,Scribble 应该改写 OnPrepareDC,在其中视打印的页数调整 DC 的原点和截割局部。

即便如此,Scribble 还是在分页方面加了一些动作。本例一份 Document 打印时被视为一张标题和一张图片的组合,因此打印一份 Document 固定要耗掉两张印表纸。我们可以这么设计:

BOOL CScribbleView::OnPreparePrinting(CPrintInfo* pInfo)
{
    pInfo->SetMaxPage(2); // 文件总共有两页经线:
    // 第一页是标题页 (title page)
    // 第二页是文件页 (图形)
    BOOL bRet = DoPreparePrinting(pInfo); // default preparation
    pInfo->m_nNumPreviewPages = 2;  // Preview 2 pages at a time
    // Set this value after calling DoPreparePrinting to override
    // value read from .INI file
    return bRet;
}

接下来打算设计一个函数用以输出标题页,一个函数用以输出文件页。后者当然应该由OnDraw 负责啰,但因为这文件页不是单纯的 Document 内容,还有所谓的表头,而这是打印时才做的东西,屏幕显示时并不需要的,所以我们希望把列印表头的工作独立于OnDraw 之外,那么最好的安置地点就是 OnPrint 了(请参考图12-5之后的补充说明的最后一点)。

Scribble Step5 把列印表头的工作独立为一个函数。总共这三个额外的函数应该声明于SCRIBBLEVIEW.H 中,其中的PrintPageHeader 在下一节列出。

表头与页尾

文件名称以及文件内容的页代码应该有地方呈现出来。屏幕上没有问题,文件名称可以出现在窗口标题,页代码可以出现在状态列;但输出到打印机上时,我们就应该设计文件的表头与页尾,分别用来放置文件名称与页代码,或其它任何你想要放的数据。显然,即使是「所见即所得」,在打印机输出与屏幕输出两方面仍然存在至少这样的差异。

我们设计了另一个辅助函数,专门负责列印表头,并将OnPrint 的参数(一个打印机 DC)传给它。有一点很容易被忽略,那就是你必得在 OnPrint 调用 OnDraw 之前调整窗口的原点和范围,以避免该页的主内容把表头页尾给盖掉了。

要补偿被表头页尾占据的空间,可以利用CPrintInfo结构中的 m_rectDraw,这个字段记录着本页的可绘图局部。我们可以在输出主内容之前先输出表头页尾,然后扣除m_rectDraw 四方形的一部份,代表表头页尾所占空间。OnPrint 也可以根据m_rectDraw的数值决定有多少内容要放在打印页的主体上。

我们甚至可能因为表头页尾的加入,而需要修改 OnDraw,因为能够放到一张印表纸上的文件内容势必将因为表头页尾的出现而减少。不过,还好本例并不是这个样子。本例不设页尾,而文件大小在 MM_LOENGLISH 映射模式下是8英吋宽9 英吋高,放在一页A4 纸张(210 x 297 毫米)或Letter Size(8-1/2 x 11英吋)纸张中都绰绰有余。

动态计算页代码

某些情况下View类在开始打印之前没办法事先知道Document的长度。假设你的程序并不支持「所见即所得」,那么屏幕上的 Document 就不会对应到它打印时真正的长度。这就引起了一个问题,你没有办法在改写 OnPreparePrinting 时,利用SetMaxPage 为 CPrintInfo 结构设定一个最大页代码,因为这时候的你根本不知道 Document的长度。而如果使用者不能够在【打印】对话框中指定「结束页代码」,Framework 也就不知道何时才停止打印的循环。唯一的方法就是边印边看,View 类必须检查是否目前已经印到Document 的尾端,并在确定之后通知Framework。

那么我们的当务之急是找出在哪一个点上检查Document结束与否,以及如何通知Framework 停止打印。从图 12-5 可知,打印的循环动作的第一个函数是 OnPrepareDC,我们可以改写此一函数,在此设一道关卡,如果检查出 Document 已到尾端,就要求中止打印。

Framework是否结束打印,其实全赖CPrintInfo 的m_bContinuePrinting 字段。此字段如果是FALSE,Framework 就中止打印。预设情况下OnPrepareDC 把此字段设为FALSE。小心,这表示如果Document 长度没有指明,Framework 就假设这份Document只有一页长。因此你在调用基类的OnPrepareDC 时需格外注意,可别总以为m_bContinuePrinting 是TRUE。

打印预览(Print Preview)

什么是打印预览?简单地说,把屏幕仿真为打印机,将图形输出于其上就是了。预览的目的是为了让使用者在打印机输出之前,先检查他即将获得的成果,检查的重要项目包括图案的布局以及分页是否合意。

为了完成预览功能,MFC 在CDC之下设计了一个子类,名为CPreviewDC。所有其他的CDC对象都拥有两个DC,它们通常井水不犯河水;然而 CPreviewDC 就不同,它的第一个DC表示被仿真的打印机,第二个DC是真正的输出目的地,也就是屏幕(预览结果输出到屏幕,不是吗 ?!)

一旦你选择【File/Print Preview】命令项,Framework 就产生一个 CPreviewDC 对象。只要你的程序曾经设定打印机DC的特征(即使没有动手设定,也有其默认值),Framework就会把同样的性质也设定到 Preview DC 上。举个例子,你的程序选择了某种打印字形,Framework 也会对屏幕选择一个仿真打印机输出的字形。一旦程序要做打印预览,Framework 就透过仿真的打印机 DC,再把结果送到显示屏DC去。

为什么我不再像前面那样去看整个预览过程中的调用堆栈并追踪其原始代码呢?因为预览对我们而言太完善了,几乎不必改写什么虚函数。唯一在 Scribble Step5 中与打印预览有关系的,就是下面这一行:

BOOL CScribbleView::OnPreparePrinting(CPrintInfo* pInfo)
{
    pInfo->SetMaxPage(2); // the document is two pages long:
    // the first page is the title page
    // the second is the drawing
    BOOL bRet = DoPreparePrinting(pInfo); // default preparation
    pInfo->m_nNumPreviewPages = 2;  // Preview 2 pages at a time
    // Set this value after calling DoPreparePrinting to override
    // value read from .INI file
    return bRet;
}

现在,Scribble Step5 全部完成。

本章回顾

前面数章中早就有了打印功能,以及预览功能。我们什么也没做,只不过在 AppWizard 的第四个步骤中选了【Printing and Print Preview】项目而已。这足可说明 MFC 为我们做掉了多少工作。想想看,一整个打印与预览系统耶。

然而我们还是要为打印付出写代码代价,原因是预设的打印大小不符理想,再者当我们想加点标题、表头、页尾时,必得亲自动手。

延续前面的风格,我还是把 MFC 提供的打印系统的背后整个原理挖了出来,使你能够清楚知道在哪里下药。在此之前,我也把 Windows 的打印原理(非关 MFC)整理出来,这样你才有循序渐进的感觉。然后,我以各个小节解释我们为 MFC 打印系统所做的补强工作。

现在的Scribble,具备了绘图能力,文件读写能力,打印能力,预览能力,丰富的窗口表现能力。除了Online Help 以及OLE 之外,所有大型软件该具备的能力都有了。我并不打算在本书之中讨论Online Help,如果你有兴趣,可以参考 Visual C++ Tutorial(可在Visual C++ 的 Online 数据中获得)第10 章。

我也不打算在本书之中讨论 OLE,那牵扯太多技术,不在本书的设定范围。

Scribble Step5 的完整原始代码,列于附录B。