技术文章

使用 losLab PDF 库将 PDF 页面缩放至 70%

PDF 页面的尺寸在创建时就已固定,因此你不能像调整图片大小那样,直接在原地对内容进行缩放。在库的模型中,让缩放变得可行的方案是“捕获并重绘”(capture-and-redraw):将每一页的内容从文档中提取到一个句柄里,以原始媒介尺寸创建一个新的空白页,然后在一个缩小的边界框内将提取出的内容重新绘制上去。周围的空白区域就成了页边距。以在 A4 页面上进行 70% 比例缩放为例,宽度的 15% 分配在页面两侧,同样比例也分配在上下两边,这正是下面边框算法所要达成的效果

CapturePage 的工作原理

CapturePage 接受一个页码作为参数,将该页面的内容提取到内存中的一个捕获对象里,并将该页面从文档的页面树中移除。这种移除是刻意为之的,这也是为什么无论迭代索引是多少,循环始终选择第 1 页的原因:一旦第 1 页被捕获并删除,原本的第 2 页就会变成新的第 1 页,依此类推。如果你让页面选择器随着循环计数器一起递增,就会跳过每隔一页的内容,最终只得到预期输出内容的一半

CapturePage 返回的捕获句柄并不是一个页面引用;它更像是一个内容快照。直到你调用 DrawCapturedPage 或者显式释放它之前,它都保持有效状态。DrawCapturedPage 需要传入该句柄以及一个由左侧偏移量、底部偏移量、宽度和高度(单位均为磅)定义的目标矩形区域。库会将捕获到的内容缩放以精确匹配该矩形区域。只有当你的矩形区域比例刚好与原始比例一致时,长宽比才会被保留。为了实现等比例缩放,你需要让该矩形等于原始尺寸乘以缩放系数,并使其在页面上居中

居中计算算法

在 70% 的缩放系数下,每个维度剩余的 30% 会被均分到两侧。因此,水平方向的内缩量为 pageWidth * (1.0 - 0.70) / 2,也就是宽度的 15%;垂直方向的内缩量同样适用该公式,只是使用页面高度计算。于是,DrawCapturedPage 的目标矩形起始于 (horizBorder, vertBorder) 坐标,跨度为宽度 pageWidth - 2 * horizBorder,高度 pageHeight - 2 * vertBorder。这套算法并不局限于本库;它只是在较大的矩形内部对称地放置一个较小矩形的常规几何计算逻辑

有一点值得注意:SetOrigin(1) 会将坐标原点设置在左上角,而不是左下角。你传给 DrawCapturedPage 的边框值是基于你设置的任意原点来测量的,因此,如果你在加载和绘制之间切换了原点模式,居中效果就会发生偏移

C# 示例

以下代码通过捕获并重绘循环处理了 Pages.pdf 的每一页,并将结果写入 newpages.pdfPDFL 是从 PDFlibDLL64.dll 添加到项目中的 ActiveX/COM 包装对象

private void ScalePages_Click(object sender, EventArgs e)
{
    File.Delete("newpages.pdf");

    double pageWidth, pageHeight, horizBorder, vertBorder;
    double scaleFactor = 0.70;
    int capturedPageId, ret;

    PDFL.LoadFromFile("Pages.pdf", "");
    PDFL.SetOrigin(1);

    int numPages = PDFL.PageCount();

    for (int i = 1; i <= numPages; i++)
    {
        // Always select page 1: CapturePage removes the page, so page 2
        // becomes page 1 on the next iteration.
        PDFL.SelectPage(1);

        pageWidth  = PDFL.PageWidth();
        pageHeight = PDFL.PageHeight();

        horizBorder = pageWidth  * (1.0 - scaleFactor) / 2;
        vertBorder  = pageHeight * (1.0 - scaleFactor) / 2;

        capturedPageId = PDFL.CapturePage(1);

        PDFL.NewPage();
        PDFL.SetPageDimensions(pageWidth, pageHeight);

        ret = PDFL.DrawCapturedPage(
            capturedPageId,
            horizBorder, vertBorder,
            pageWidth  - 2 * horizBorder,
            pageHeight - 2 * vertBorder);
    }

    PDFL.SaveToFile("newpages.pdf");
}

Delphi 示例

Delphi 版本直接使用 TPDFlib 而不是通过 COM 层,但调用顺序完全一致。一个实际的区别在于输出文件的保护机制:这里使用了 FileExists 配合 DeleteFile 而非 File.Delete,因为如果上一次运行生成的目标文件仍在一个阅读器中被打开并锁定时,SaveToFile 就会失败

procedure TForm1.ScalePagesClick(Sender: TObject);
var
  PDFLib: TPDFlib;
  pageWidth, pageHeight, horizBorder, vertBorder: Double;
  scaleFactor: Double;
  capturedPageId, ret, numPages, i: Integer;
begin
  if FileExists('newpages.pdf') then
    DeleteFile('newpages.pdf');

  scaleFactor := 0.70;

  PDFLib := TPDFlib.Create;
  try
    PDFLib.LoadFromFile('Pages.pdf', '');
    PDFLib.SetOrigin(1);

    numPages := PDFLib.PageCount();

    for i := 1 to numPages do
    begin
      PDFLib.SelectPage(1);

      pageWidth  := PDFLib.PageWidth();
      pageHeight := PDFLib.PageHeight();

      horizBorder := pageWidth  * (1.0 - scaleFactor) / 2;
      vertBorder  := pageHeight * (1.0 - scaleFactor) / 2;

      capturedPageId := PDFLib.CapturePage(1);

      PDFLib.NewPage();
      PDFLib.SetPageDimensions(pageWidth, pageHeight);

      ret := PDFLib.DrawCapturedPage(
        capturedPageId,
        horizBorder, vertBorder,
        pageWidth  - 2 * horizBorder,
        pageHeight - 2 * vertBorder);
    end;

    PDFLib.SaveToFile('newpages.pdf');
  finally
    PDFLib.Free;
  end;
end;

缩放系数究竟控制了什么

这里的 0.70 这个值意味着渲染内容占据每个页面维度的 70%,而不是说文件变成原来字节大小的 70%。此操作后的文件大小取决于原始内容的复杂程度;一个带有大量图片的页面不会按比例缩小,因为像素数据是以相同的分辨率重绘到了一个更小的区域内。如果目标是字节级别的压缩,正确的方法是使用 LinearizeFile 或通过流压缩重新保存,而不是使用几何缩放

70% 这个数字也不是硬性限制。0.0 到 1.0 之间的任何值都可以生效,而大于 1.0 的值则会将内容放大超出原始页面边界,除非你也同时增加页面尺寸,否则超出部分会在介质框边缘被裁剪掉。混合尺寸的文档也能被自然地处理,因为 PageWidthPageHeight 是在边框计算之前针对每一页进行查询的,所以一个奇数页为 A4、偶数页为 A3 的文档会在各页面尺寸上产生正确的居中输出效果,而无需任何特殊的处理逻辑

哪里可能会出错

在实际操作中有两种常见的失败模式。第一种是输出文件在 PDF 阅读器中仍被前一次运行打开着:此时 SaveToFile 会因为被占用而导致失败,或者写入零字节(取决于具体平台),新的输出结果无法落地。函数顶部的删除文件保护逻辑在开发阶段能解决这个问题,但在生产环境中,写入到一个临时路径并在成功后再进行重命名会更加安全

第二种是页数不匹配。因为 CapturePage 在处理页面时会将页面从文档中移除,所以你在循环之前通过 PageCount() 读取的页数才是用来迭代的正确边界。在循环内部调用 PageCount() 将导致在每次遍历时返回一个递减的数字,从而过早退出,留下最后几页未被处理。示例代码中的循环变量仅仅充当一个剩余迭代次数的计数器;它永远不会被用来选择页面,因为要选择的页面始终是第 1 页,理由在前面已经解释过了

本文展示的包含 CapturePageDrawCapturedPage 以及 SetPageDimensions 在内的页面操作调用,属于 losLab PDF 库 的一部分,该库适用于 Delphi、C#、VB.NET 以及 C++ 语言