HotPDF 用 THotPDF.FlattenLoadedPageRotation 攤平 PDF 頁面旋轉:這個方法把每個旋轉頁的內容包進一個順時針的 cm 變換,改寫該頁實際擁有的每個頁面框,把註解幾何、外觀矩陣、顯式目標與標籤結構幾何都轉過同樣的角度,然後把 /Rotate 設成 0。頁面在檢視器裡看起來一模一樣,座標系卻已經站正。一旦下游工具、列印 RIP 或您自己的蓋章程式碼無視 /Rotate、直接在原始使用者空間裡擺東西,這件事就重要了
典型的觸發者是掃描器或行動擷取 App,把橫向頁寫成帶 /Rotate 90 的直向媒體。每個檢視器都顯示正確,所以沒人察覺,直到有人在「右下角」蓋頁碼、它卻沿著左邊緣躺著出現,或某個只讀 /MediaBox 的拼版步驟為橫向頁排了一個直向槽位。攤平聽起來是一行矩陣的事。實際上它碰五個頁面框、三種註解幾何、文件的連結目標與結構樹,而每一項在 ISO 32000-1 裡都有自己的規則
/Rotate 把 PDF 頁面往哪個方向轉?
/Rotate 為顯示與列印把頁面順時針轉,以 90 度的倍數為單位(ISO 32000-1 §7.7.3.3,表 30)。轉 90 度時,媒體的左緣變成上緣、上緣變成右側,所以在 y 向下的裝置空間裡,映射是 X = (y - Bottom) * Scale 與 Y = (x - Left) * Scale。轉 270 度時,右緣變成上緣。/Rotate 也是僅有的四個可繼承頁面屬性之一,與 /Resources、/MediaBox、/CropBox 並列(§7.7.3.4),所以自己沒有 /Rotate 的頁面字典,照樣可能被 /Pages 祖先轉動。THotPDF.GetLoadedPageRotation 會走 /Parent 鏈,把結果規整成 0–359——您要的是這個值,不是頁面上的原始鍵
方向有一種錯法能在測試中存活,早期 HotPDF 建置就錯得一模一樣。舊的頁面到裝置矩陣在 90 與 270 度時把 y 分量對調,得到的是沿對角線的鏡射而不是旋轉:矩陣的朝向相對未旋轉情形翻了過來。兩個角度看起來都「有轉」,點陣圖的寬高也對調了,從頁面到檢視再轉回來的往返也回到起點,於是尺寸檢查與往返測試全數通過。唯一可靠的檢查是角落標記最後落在哪裡,與參考渲染器逐像素比對。因為檢視器模型、SIMD 渲染後端與高亮映射都複製了同一個矩陣,這次一起修正,攤平程式碼現在用的是與渲染器一致的順時針慣例
FlattenLoadedPageRotation 怎麼改寫一頁
FlattenLoadedPageRotation(PageRange, Info) 處理 PageRange 裡有效旋轉為 90、180 或 270 的每一頁,回傳攤平的頁數。PageRange 給空字串代表全部頁面;否則字串用慣常的 1 起算 '1-3,7' 語法,超出範圍的頁號會丟例外而不是被跳過。原始內容串流永不重新編碼。方法在頁面的 /Contents 前面加上一條含 q 0 -1 1 0 -Bottom Width+Left cm(90 度時)的新串流、在後面補一條含 Q 的串流,最後把顯式的 /Rotate 0 寫進頁面字典,讓 /Pages 節點上的繼承值無法再轉一次
var
Pdf: THotPDF;
Info: THPDFRotationFlattenInfo;
Flattened: Integer;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('scanned-batch.pdf') > 0 then
begin
// '' 代表全部頁面;0 度的頁面會掃到但不動
Flattened := Pdf.FlattenLoadedPageRotation('', Info);
Writeln(Format('Scanned %d, flattened %d pages', [Info.ScannedPageCount, Info.FlattenedPageCount]));
Writeln(Format('Turned %d annotations, %d destinations, %d tagged geometry entries',
[Info.TransformedAnnotationCount, Info.TransformedDestinationCount,
Info.TransformedStructureGeometryCount]));
if Flattened > 0 then
Pdf.SaveLoadedDocument('scanned-batch-upright.pdf');
end;
finally
Pdf.Free;
end;
end;
THPDFRotationFlattenInfo 記錄值得寫進日誌而不是丟掉。ScannedPageCount 是範圍的大小,FlattenedPageCount 等於回傳值,三個 Transformed... 計數器告訴您文件裡有沒有指向被轉頁面的連結、書籤或標籤幾何。一批檔案全部回報零目的地,沒問題;標籤化的 PDF/UA 檔在您以為會有圖形邊界框時回報零結構幾何,那就是該手工檢查一下的訊號
攤平會改寫哪些頁面框、按什麼順序?
攤平只改寫頁面已有的框,而且寫任何一個之前先把每個框都讀完。順序要緊是因為預設鏈:GetLoadedPageBox(PageIndex, pbCropBox, ...) 在頁面沒有 /CropBox 時回傳 /MediaBox,而 /BleedBox、/TrimBox 與 /ArtBox 預設取 CropBox(§14.11.2)。更早的版本一次讀、轉、寫一個框:先改寫 MediaBox,再讀「CropBox」,拿回的是已經轉過的 MediaBox,又轉一次,寫出一個頁面從未有過的 CropBox,把一頁橫向文件裁成正方形。繼承規則也同樣一分為二:MediaBox 與 CropBox 沿 /Parent 鏈查找,Bleed、Trim 與 ArtBox 只有坐在頁面字典本身上才算數,所以 /Pages 節點上走失的 /TrimBox 視同不存在,不會被複製到頁面上
procedure DumpPageGeometry(Pdf: THotPDF; PageIndex: Integer);
var
L, B, R, T: Single;
begin
Writeln('Effective /Rotate: ', Pdf.GetLoadedPageRotation(PageIndex));
if Pdf.GetLoadedPageBox(PageIndex, pbMediaBox, L, B, R, T) then
Writeln(Format('MediaBox [%g %g %g %g]', [L, B, R, T]));
// 沒有 /TrimBox 鍵也回 True:值先退到 CropBox,再退到 MediaBox
if Pdf.GetLoadedPageBox(PageIndex, pbTrimBox, L, B, R, T) then
Writeln(Format('TrimBox [%g %g %g %g]', [L, B, R, T]));
// 預設 Letter;失敗時 GetLoadedPageVisibleBox 不動輸出參數
L := 0; B := 0; R := 612; T := 792;
Pdf.GetLoadedPageVisibleBox(PageIndex, L, B, R, T);
Writeln(Format('Visible [%g %g %g %g]', [L, B, R, T]));
end;
攤平前後各跑一次這個輔助函式,數字自己會說話。MediaBox 為 [0 0 612 792] 的 90 度頁,攤平後 MediaBox 變成 [0 0 792 612];每個被改寫的框都按同一個順時針轉、相對原始 MediaBox 原點映射,所以新 MediaBox 永遠從原點出發,其他框在其中的位置不變。GetLoadedPageVisibleBox 回傳檢視器顯示、印表機列印的那個框——裁到 MediaBox 的 CropBox,規整成 Left 小於 Right——HotPDF 的渲染器、SVG 匯出、檢視與列印路徑用的都是同一個框。需要人眼看到的頁面尺寸時,呼叫 GetLoadedPageVisibleBox,別去讀 /MediaBox
只轉 /Rect,註解為什麼會壞?
註解會壞,是因為外觀串流並不是直接畫進 /Rect。按 §12.5.5,檢視器先用表單的 /Matrix 變換其 /BBox,再把結果的邊界框縮放、平移進 /Rect。只轉 /Rect,一枚 200 × 40 的印章就被擠進 40 × 200 的槽位,橫躺著、讀不出來。所以 FlattenLoadedPageRotation 把頁面的順時針轉右乘到每個外觀 /Matrix 上(90 度即列向量慣例下的 [0 -1 1 0 0 0]),遍及 /N、/R、/D 三種外觀與其中的每個狀態。一條外觀串流可能被多個註解或狀態共享,所以每次呼叫每條串流恰好轉一次。沒有乾淨答案的唯一情況,是被不同旋轉的頁面共享的串流:它跟著第一個碰到它的頁面走
還有兩條規則保住表單欄位與便利貼。Widget 的 /MK /R 條目(§12.5.6.19)是逆時針角度,所以要用它減去頁面的順時針角度、對 360 取模;漏了這步,下次外觀重生時欄位文字就畫反方向。帶 NoRotate 旗標的註解(位元 5、值 16,§12.5.3)在旋轉頁上保持直立、以自己 /Rect 的左上角為樞,所以攤平保住它們的寬、高與直立外觀,只把那個角搬到轉動後的位置。註解之外,方法也轉 /QuadPoints、/Vertices、/L 與 /InkList,改寫指名該頁的顯式目的地(/XYZ 點、/FitR 矩形,以及 90 與 270 度時互換的 /FitH / /FitV,§12.3.2.2),並變換 /Pg 指向該頁的結構元素的屬性 /BBox 之類標籤幾何
攤平不管哪些事?
攤平是對一頁自有物件的幾何改寫,以下幾種情況它不聲不響地置身事外,而不是大聲報錯
- 有效旋轉已是 0、或 MediaBox 缺失、寬高為零的頁面直接跳過、不報錯;把回傳值與您預期要變的頁數比一比
- 頁面資源引用的 Form XObjects 保留自己在 form 空間裡的
/BBox,因為外層cm已經轉過它們;結構樹掃描只跟/K與/A,所以不會二次走進頁面資源或註解 - 目的地靠每攤平一頁就把全部間接物件掃一遍找到,所以有數百個旋轉頁的大文件,每頁都要為那次走訪付費
- HotPDF 的頁面渲染器不畫註解,所以要目視檢查轉過的印章,得先跑
FlattenLoadedAnnotations
// 把外觀烘進內容,渲染器才顯示得出來,
// 然後在移除 /Rotate 前後各渲染第 1 頁
Pdf.FlattenLoadedAnnotations('1');
Before := Pdf.RenderLoadedPageToBitmap(0, 96);
try
Pdf.FlattenLoadedPageRotation('1', Info);
After := Pdf.RenderLoadedPageToBitmap(0, 96);
try
Assert((Before.Width = After.Width) and (Before.Height = After.Height));
// 在這裡比對角落標記的像素,別只比尺寸
finally
After.Free;
end;
finally
Before.Free;
end;
想深入背景,這個故事的註解面延續在攤平前合成註解外觀,前後對比背後的渲染器見把已載入的 PDF 頁面渲染成點陣圖,已載入 PDF 上的遮蓋與 N-up 拼接則展示了旋轉前綴與後綴所依賴的同一個內容串流附加技巧。HotPDF 連同 FlattenLoadedPageRotation 與各頁面框讀取器,都提供給 Delphi 與 C++Builder,見HotPDF Delphi PDF component 頁面