บทความเทคนิค

flatten การหมุนหน้า PDF โดยไม่พัง page box ใน Delphi

HotPDF flatten การหมุนของหน้า PDF ด้วย THotPDF.FlattenLoadedPageRotation: เมธอดนี้ห่อเนื้อหาของหน้าที่หมุนอยู่ด้วย transform cm ตามเข็มนาฬิกา เขียน page box ทุกชนิดที่หน้านั้นมีจริงใหม่ หมุนเรขาคณิตของ annotation, matrix ของ appearance, explicit destination และเรขาคณิตของโครงสร้างแบบ tagged ด้วยมุมเดียวกัน แล้วจึงตั้ง /Rotate เป็น 0 หน้าดูเหมือนเดิมเป๊ะใน viewer แต่ระบบพิกัดของมันตอนนี้ตั้งตรงแล้ว เรื่องนี้สำคัญทันทีที่ tool ปลายทาง, print RIP หรือโค้ดปั๊มตราของคุณเองเมิน /Rotate แล้วไปวางของลง user space ดิบ ๆ

ตัวจุดชนวนทั่วไปคือเครื่องสแกนหรือแอป capture บนมือถือที่เขียนหน้าแนวนอนเป็น media แนวตั้งพร้อม /Rotate 90 viewer ทุกตัวแสดงถูกต้อง จึงไม่มีใครสังเกตจนกระทั่งมีคนปั๊มเลขหน้าที่ "ล่างขวา" แล้วมันไปนอนตะแคงอยู่ขอบซ้าย หรือขั้น imposition ที่อ่านแค่ /MediaBox จัดช่องแนวตั้งให้หน้าแนวนอน การ flatten ฟังดูเป็นงาน matrix บรรทัดเดียว ในทางปฏิบัติมันแตะ page box ห้าชนิด เรขาคณิตของ annotation สามแบบ จุดหมายของลิงก์ในเอกสารและต้นไม้โครงสร้าง โดยแต่ละอย่างมีกฎของตัวเองใน ISO 32000-1

/Rotate หมุนหน้า PDF ทางไหน

/Rotate หมุนหน้าตามเข็มนาฬิกาเพื่อแสดงผลและพิมพ์ เป็นทวีคูณของ 90 องศา (ISO 32000-1 §7.7.3.3, Table 30) ที่ 90 องศาขอบซ้ายของ media กลายเป็นด้านบนและขอบบนกลายเป็นด้านขวา ใน device space แบบ y ชี้ลง mapping จึงเป็น X = (y - Bottom) * Scale กับ Y = (x - Left) * Scale ที่ 270 องศาขอบขวากลายเป็นด้านบน /Rotate ยังเป็นหนึ่งใน attribute ของหน้าที่สืบทอดได้เพียงสี่ตัว เคียงกับ /Resources, /MediaBox กับ /CropBox (§7.7.3.4) dictionary ของหน้าที่ไม่มี /Rotate ของตัวเองจึงยังถูกหมุนได้ด้วยบรรพบุรุษ /Pages THotPDF.GetLoadedPageRotation เดินตาม chain ของ /Parent แล้ว normalize ผลลัพธ์เป็น 0-359 ซึ่งเป็นค่าที่คุณต้องการ ไม่ใช่ key ดิบบนหน้า

ทิศทางเป็นอะไรที่พลาดง่ายแบบที่ผ่านการทดสอบมาได้ และ build เก่าของ HotPDF ก็เคยพลาดแบบนั้นจริง matrix หน้าไป device รุ่นเก่าสลับส่วน y กันในกรณี 90 กับ 270 องศา ซึ่งได้การสะท้อนข้ามเส้นทแยงมุม ไม่ใช่การหมุน ทิศทางของ matrix จึงกลับด้านเทียบกับกรณีไม่หมุน ทั้งสองมุมยัง "ดูเหมือนหมุน" bitmap ได้กว้างกับสูงสลับกัน และ round trip จากหน้าไป view แล้วกลับก็ได้จุดเริ่มต้นเดิม เช็คมิติกับชุดทดสอบ round trip เลยผ่านหมด เช็คที่เชื่อถือได้ตัวเดียวคือดูว่า marker ที่มุมไปตกจุดไหน เทียบทีละ pixel กับตัว render อ้างอิง เพราะโมเดล viewer, render backend แบบ SIMD และ mapping ของ highlight ลอก matrix เดิมมาใช้หมด ทุกตัวจึงถูกแก้พร้อมกัน และโค้ด flatten ตอนนี้ใช้ convention ตามเข็มแบบเดียวกับ renderer

HotPDF flatten การหมุนหน้าใน Delphi อย่างไร: หน้าแนวตั้งที่เก็บด้วย /Rotate 90 แสดงตามเข็มเป็น view แนวนอน 792 คูณ 612 mapping ของ device X = (y - Bottom) * Scale, Y = (x - Left) * Scale ย้ายมุมทุกจุด และการสลับส่วน y ของ matrix ผลิตการสะท้อนที่จับได้เฉพาะด้วยการเทียบ marker ที่มุม
viewer หมุนหน้าตามเข็มเพื่อแสดงผลขณะที่ไบต์ยังเป็นแนวตั้ง — GetLoadedPageRotation เดินตาม chain ของ /Parent ก่อน เพราะ /Rotate เป็นหนึ่งในสี่ attribute ของหน้าที่สืบทอดได้

FlattenLoadedPageRotation เขียนหน้าใหม่อย่างไร

FlattenLoadedPageRotation(PageRange, Info) จัดการทุกหน้าใน PageRange ที่มีการหมุนจริงเป็น 90, 180 หรือ 270 องศา และคืนจำนวนหน้าที่ flatten ได้ PageRange ว่างหมายถึงทุกหน้า ไม่งั้น string ใช้ syntax นับจากหนึ่งปกติอย่าง '1-3,7' และเลขหน้านอกช่วงจะยก exception แทนการข้ามผ่าน content stream เดิมไม่เคยถูกเข้ารหัสใหม่ เมธอดเติม stream ใหม่ที่มี q 0 -1 1 0 -Bottom Width+Left cm (สำหรับ 90 องศา) เข้าหน้าของ /Contents ไว้ต้น ต่อท้ายด้วย stream ที่มี Q แล้วเขียน /Rotate 0 แบบชัดเจนลง dictionary ของหน้าเป็นอันสุดท้าย เพื่อไม่ให้ค่าที่สืบทอดบน node ของ /Pages หมุนหน้าซ้ำอีกรอบ

var
  Pdf: THotPDF;
  Info: THPDFRotationFlattenInfo;
  Flattened: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('scanned-batch.pdf') > 0 then
    begin
      // '' คือทุกหน้า; หน้าที่ 0 องศาถูก scan แต่ไม่ถูกแตะ
      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;

record THPDFRotationFlattenInfo สมควรถูก log ไว้ ไม่ใช่ทิ้ง ScannedPageCount คือขนาดของช่วง FlattenedPageCount เท่ากับค่าที่คืนมา และตัวนับ Transformed... สามตัวบอกว่าเอกสารมีลิงก์ bookmark หรือเรขาคณิตแบบ tagged ชี้ไปที่หน้าที่ถูกหมุนหรือเปล่า ชุดงานที่ทุกไฟล์รายงาน destination เป็นศูนย์ก็ปกติดี แต่ไฟล์ tagged แบบ PDF/UA ที่รายงานเรขาคณิตโครงสร้างเป็นศูนย์ทั้งที่คุณคาดว่าจะเจอ bounding box ของ figure นั่นเป็นสัญญาณให้เปิดตรวจด้วยมือ

flattening เขียน page box ไหนใหม่ และเรียงลำดับอย่างไร

flattening เขียนใหม่เฉพาะ box ที่หน้ามีอยู่จริง และมันอ่านทุก box เสร็จก่อนจะเขียนสักตัว ลำดับสำคัญเพราะ chain ของค่าเริ่มต้น: GetLoadedPageBox(PageIndex, pbCropBox, ...) คืน /MediaBox เมื่อหน้าไม่มี /CropBox และ /BleedBox, /TrimBox กับ /ArtBox เริ่มต้นที่ CropBox (§14.11.2) รุ่นก่อนหน้าอ่าน แปลงแล้วเขียน box ทีละตัว มันเขียน MediaBox ใหม่ก่อน แล้วไปอ่าน "CropBox" ก็ได้ MediaBox ที่ถูกหมุนไปแล้วกลับมา จึงหมุนมันอีกรอบแล้วเขียน CropBox ที่หน้าไม่เคยมี ตัดหน้าแนวนอนให้เหลือสี่เหลี่ยมจัตุรัสไปเฉย ๆ กฎการสืบทอดก็แบ่งแยกแบบเดียวกัน: MediaBox กับ CropBox ถูกค้นตาม chain ของ /Parent ขณะที่ Bleed, Trim กับ ArtBox นับเฉพาะเมื่ออยู่บน dictionary ของหน้าเอง /TrimBox ที่หล่นอยู่บน node ของ /Pages จึงถูกถือว่าไม่มีอยู่และไม่เคยถูกคัดลอกลงหน้า

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]));
  // True แม้ไม่มี key /TrimBox: ค่าตกไปที่ 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 ปล่อย output ไว้ตามเดิมเมื่อล้ม
  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;

รัน helper นี้ก่อนกับหลัง flatten แล้วตัวเลขจะอธิบายตัวเอง หน้า 90 องศาที่ MediaBox เป็น [0 0 612 792] MediaBox หลัง flatten จะเป็น [0 0 792 612] box ที่เขียนใหม่ทุกตัวถูก map ผ่านการหมุนตามเข็มแบบเดียวกัน เทียบกับจุดกำเนิดของ MediaBox เดิม MediaBox ใหม่จึงเริ่มที่จุดกำเนิดเสมอและ box อื่นคงตำแหน่งของตัวเองข้างใน GetLoadedPageVisibleBox คืนสิ่งที่ viewer แสดงและเครื่องพิมพ์พิมพ์ คือ CropBox ที่ถูกครอบด้วย MediaBox และ normalize ให้ Left น้อยกว่า Right และ renderer ของ HotPDF, export ฝั่ง SVG, viewer กับเส้นทางพิมพ์ล้วนใช้ box ตัวนี้ เมื่อต้องการขนาดหน้าที่มนุษย์เห็น ให้เรียก GetLoadedPageVisibleBox แทนการไปอ่าน /MediaBox ตรง ๆ

ทำไม HotPDF ต้องอ่าน page box ทุกตัวก่อนเขียนตัวใดระหว่าง FlattenLoadedPageRotation: BleedBox, TrimBox กับ ArtBox เริ่มต้นที่ CropBox ซึ่งเองก็ตกไปที่ MediaBox การหมุน box ทีละตัวจึงทำให้ CropBox อ่าน MediaBox ที่ถูกเขียนใหม่ไปแล้ว และการหมุนรอบที่สองเขียน box ที่หน้าไม่เคยมี ตัดหน้าแนวนอนเหลือจัตุรัส
chain ของค่าเริ่มต้นหมายความว่า output ของ box หนึ่งคือ input ของอีก box — อ่านทุกอย่างให้ครบก่อน แปลงโดยอิงจุดกำเนิดของ MediaBox เดิม แล้วจึงเขียน

ทำไม annotation พังเมื่อคุณหมุนแค่ /Rect

annotation พังเพราะ appearance stream ไม่ได้ถูกวาดลง /Rect ตรง ๆ ใต้ §12.5.5 viewer แปลง /BBox ของ form ด้วย /Matrix ของมันก่อน แล้วค่อยสเกลกับเลื่อน bounding box ของผลลัพธ์นั้นเข้าไปใน /Rect หมุนแค่ /Rect ตราปั๊มขนาด 200 × 40 ก็จะถูกบีบลงช่อง 40 × 200 อ่านไม่ออกแถมตะแคงอยู่อย่างนั้น FlattenLoadedPageRotation จึงคูณเข้าทางขวาของการหมุนตามเข็มของหน้าลงบน /Matrix ของ appearance แต่ละตัว (สำหรับ 90 องศาคือ [0 -1 1 0 0 0] ใน convention แถวเวกเตอร์) ครอบคลุม appearance ทั้ง /N, /R กับ /D และทุกสถานะข้างใน appearance stream หนึ่งตัวอาจถูกหลาย annotation หรือหลายสถานะใช้ร่วมกัน stream แต่ละตัวจึงถูกหมุนพอดีหนึ่งครั้งต่อหนึ่งการเรียก กรณีเดียวที่ไม่มีคำตอบสะอาดคือ stream ที่ถูกใช้ร่วมข้ามหน้าที่หมุนไม่เท่ากัน มันจะตามหน้าแรกที่ไปถึงมัน

อีกสองกฎช่วยรั้ง form field กับ sticky note ให้อยู่ที่เดิม entry ของ /MK /R ของ widget (§12.5.6.19) เป็นมุมทวนเข็ม มุมตามเข็มของหน้าจึงถูกหักออกจากมัน modulo 360 ข้ามขั้นตอนนี้ไป appearance รอบถัดไปจะวาดข้อความใน field หัวทิศผิด annotation ที่มีธง NoRotate (ตำแหน่ง bit 5, ค่า 16, §12.5.3) ยืนตรงบนหน้าที่หมุนและหมุนรอบมุมบนซ้ายของ /Rect ของมัน flattening จึงคงความกว้าง ความสูงและการยืนตรงของพวกมันไว้ แล้วย้ายแค่มุมนั้นไปจุดที่การหมุนวางมัน ไกลกว่า annotation เมธอดยังหมุน /QuadPoints, /Vertices, /L กับ /InkList เขียน explicit destination ที่อ้างชื่อหน้าใหม่ (จุดของ /XYZ, สี่เหลี่ยมของ /FitR และสลับ /FitH / /FitV ที่ 90 กับ 270 องศา, §12.3.2.2) และแปลงเรขาคณิตแบบ tagged อย่าง entry /BBox ของ attribute สำหรับ structure element ที่ /Pg ชี้มาที่หน้านี้

ทำไม annotation พังเมื่อหน้าของ HotPDF ถูก flatten โดยหมุนแค่ /Rect: ตราปั๊ม 200 คูณ 40 ถูกสเกลลงช่อง 40 คูณ 200 และอ่านไม่ออก FlattenLoadedPageRotation จึงคูณเข้าทางขวาของการหมุนตามเข็มลง /Matrix ของ appearance แต่ละตัวครอบคลุม /N, /R กับ /D ปรับ /MK /R ที่ทวนเข็มและหมุน annotation NoRotate รอบมุมบนซ้ายของมัน
viewer ดัน BBox ที่ถูกแปลงแล้วของ appearance เข้า /Rect stream เองจึงต้องถูกหมุน — หนึ่งรอบต่อ appearance ที่ใช้ร่วมกัน พอดีหนึ่งครั้งต่อหนึ่งการเรียก

flattening ไม่ครอบคลุมอะไร

flattening เป็นการเขียนเรขาคณิตใหม่เฉพาะ object ของหน้าหนึ่ง ๆ และหลายสถานการณ์ตกออกนอกขอบเขตแบบเงียบ ๆ ไม่ใช่ดัง ๆ

  • หน้าที่การหมุนจริงเป็น 0 อยู่แล้ว หรือที่ MediaBox หายไปหรือมีกว้างหรือสูงเป็นศูนย์ ถูกข้ามโดยไม่มี error เทียบค่าที่คืนมากับจำนวนหน้าที่คุณคาดว่าจะเปลี่ยน
  • Form XObject ที่ถูกอ้างจาก resource ของหน้าคง /BBox ของตัวเองไว้ใน form space เพราะ cm ชั้นนอกหมุนมันไปแล้ว การกวาดต้นไม้โครงสร้างตามเฉพาะ /K กับ /A จึงไม่มีวันเดินซ้ำเข้าไปใน resource หรือ annotation ของหน้าอีกรอบ
  • destination ถูกค้นโดยกวาด indirect object ทุกตัวหนึ่งรอบต่อหนึ่งหน้าที่ flatten เอกสารใหญ่ที่มีหน้าหมุนเป็นร้อยจึงจ่ายค่าเดินนี้ในทุกหน้า
  • renderer หน้าของ HotPDF ไม่วาด annotation การตรวจภาพตราปั๊มที่ถูกหมุนจึงต้องเรียก FlattenLoadedAnnotations ก่อน
// เบค appearance ลงเนื้อหาเพื่อให้ renderer แสดงมันได้
// แล้ว render หน้า 1 ก่อนกับหลังเอา /Rotate ของมันออก
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));
    // เทียบ pixel ของ marker ที่มุมตรงนี้ ไม่ใช่แค่ขนาด
  finally
    After.Free;
  end;
finally
  Before.Free;
end;

พื้นหลังที่ลึกกว่านั้น ฝั่ง annotation ของเรื่องนี้ไปต่อที่การสังเคราะห์ appearance ของ annotation ก่อน flatten renderer ที่อยู่เบื้องหลังการเทียบก่อนกับหลังอยู่ในการ render หน้า PDF ที่โหลดไว้ลง bitmap และการ redact กับเย็บ N-up บน PDF ที่โหลดไว้โชว์เทคนิคเติม content-stream แบบเดียวกับที่ prefix กับ suffix ของการหมุนพึ่งพา HotPDF รวมถึง FlattenLoadedPageRotation กับตัวอ่าน page box พร้อมใช้สำหรับ Delphi กับ C++Builder ที่หน้า HotPDF Delphi PDF component