技術文章

用 losLab PDF Library 把 PDF 頁面縮放為 70%

PDF 的頁面尺寸在頁面建立當下就固定了,所以您沒辦法像調整影像大小那樣,直接就地重新縮放內容。讓縮小這件事變得可行的程式庫模型是擷取後重畫:把每一頁的內容從文件中提取到一個控制代碼裡,以原本的媒體尺寸建立一頁全新的空白頁,再把擷取到的內容以縮小後的定界方框畫回去。四周的留白就成了邊界。舉例來說,在 A4 頁面上以 70% 縮放時,左右各吃掉寬度的 15%,上下也是同樣的比例,而這正是下面的邊界算式算出來的結果

PDF:擷取後重畫的流程,每一頁 PDF 都被擷取進一個控制代碼,再畫回一頁維持原本媒體尺寸的全新空白頁上
擷取後重畫會把每一頁的內容提取進控制代碼,再以縮小後的尺寸重畫到一頁維持原尺寸的新頁面上

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。這套算術與程式庫無關,它就只是把一個較小的矩形對稱地塞進較大矩形裡的幾何

PDF:在 70% 縮放下,目的地矩形置中於原本的頁面上,四邊各算出 15% 的邊界
縮放到 70% 會在每一邊留下 15% 的邊界,而 DrawCapturedPage 收到的就是那個以點為單位的置中矩形

有一點值得注意:SetOrigin(1) 會把座標原點放在左上角,而不是左下角。您傳給 DrawCapturedPage 的邊界值,是從您設定的那個原點量起的,所以如果您在載入與繪製之間切換了原點模式,置中就會跑掉

C# 範例

下面這段程式碼把 Pages.pdf 的每一頁都跑過擷取後重畫的循環,並把結果寫進 newpages.pdf。PDFL 是從 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++)
    {
        // 永遠選第 1 頁:CapturePage 會移除該頁,所以下一輪
        // 迭代時第 2 頁就變成第 1 頁。
        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 的值會把內容放大到超出原本的頁面界線,除非您同時加大頁面尺寸,否則會在媒體方框邊緣被裁掉。尺寸混雜的文件天生就處理得來,因為 PageWidth 與 PageHeight 是在算邊界之前逐頁查詢的,所以一份奇數頁是 A4、偶數頁是 A3 的文件,不必任何特例處理,在每種頁面尺寸上都會產生正確置中的輸出

哪些地方會出錯

實務上會碰到兩種失效模式。第一種是輸出檔案還被上一輪執行留在 PDF 檢視器中開著:視平台而定,SaveToFile 會失敗或寫出零位元組,新的輸出永遠落不了地。函式開頭那道刪檔防護在開發時夠用,但在生產流水線裡,先寫到暫存路徑、成功後再改名會更安全

第二種是頁數對不上。由於 CapturePage 會邊處理邊把頁面從文件中移除,迴圈之前從 PageCount() 讀到的數字,才是正確的迭代上限。在迴圈內部呼叫 PageCount() 每一輪都會回傳愈來愈小的數字,導致提早結束,把最後幾頁留著沒處理。範例中的迴圈變數只當作剩餘迭代次數的計數器,它從不用來選頁,因為基於前面解釋過的理由,要選的頁永遠是第 1 頁

PDF:迴圈示意圖,說明為什麼每一輪都必須呼叫 SelectPage(1),因為被擷取的頁面已從頁面樹移除,剩下的頁面會往前遞補
CapturePage 會移除每一頁處理過的頁面,於是佇列自己重新編號,這讓第 1 頁成為唯一正確的選擇器值

本文示範的頁面操作呼叫,包含 CapturePage、DrawCapturedPage 與 SetPageDimensions,都是適用於 Delphi、C#、VB.NET 與 C++ 的 losLab PDF Library 的一部分