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

PDFium progressive download กับ cancel ใน Delphi

PDFium Component เปิด PDF ที่ยังกำลังดาวน์โหลดอยู่ผ่าน TPdfProgressiveDocument ซึ่งเป็น subclass ของ TPdf ที่ห่อ API availability FPDFAvail_* ของ PDFium BeginProgressiveLoad เริ่ม session, CheckDocumentAvailability รายงานช่วงไบต์ที่ PDFium ยังต้องการ, OpenProgressiveDocument เปิดไฟล์เมื่อไบต์พอแล้ว และ CancelProgressiveLoad ละทิ้งการดาวน์โหลดที่ถูกขัดจังหวะโดยไม่รั่ว native handle ส่วนที่ยากไม่ใช่ทางสบาย viewer บนเน็ตที่วูบวาบจะเจอผู้ใช้ปิดแท็บตอน 25 เปอร์เซ็นต์ เปลี่ยนใจ แล้วเปิดลิงก์เดิมอีกครั้ง ทุก session ที่ถูกยกเลิกพวกนี้มี native availability handle หนึ่งตัว, record callback ภาษา C สองตัว, stream adapter หนึ่งตัว และชุด range request ที่ยังบินอยู่ ซึ่งต้องปล่อยตามลำดับที่ถูกต้องเป๊ะ ๆ

TPdfProgressiveDocument โหลด PDF ที่ยังกำลังดาวน์โหลดอย่างไร

TPdfProgressiveDocument รักษา availability provider ของ PDFium ให้มีชีวิตขณะ stream แบบ random access ถูกเติมเต็ม และถาม provider นั้นก่อนทุกขั้นตอน parse ว่าไบต์ที่มันต้องการมาครบหรือยัง BeginProgressiveLoad(AStream, AFileSize, AOwnsStream, AInitialAvailableByteCount) รับ backing stream บวกขนาดตรรกะของไฟล์รีโมต เดินสาย callback IsDataAvail กับ callback AddSegment เข้าสอง record แล้วเรียก FPDFAvail_Create เมื่อ PDFium ถามว่าช่วงหนึ่งมาหรือยัง component จะตอบใช่ถ้าช่วงนั้นอยู่ใน prefix ต่อเนื่องที่ AvailableByteCount อธิบายไว้ หรืออยู่ในช่วงที่จบแล้วผ่าน scheduler RangeRequests และ event OnDataAvailable สามารถล้มคำตัดสินนี้ให้ store แบบเจาะรูได้ ทุกการเรียก CheckDocumentAvailability คืนค่า TPdfDataAvailability หนึ่งในสามค่า (pdaAvailable, pdaNotAvailable, pdaError) พร้อมส่งช่วงที่ PDFium ขอกลับมาเป็น array TPdfDownloadRanges ที่เรียงแล้วรวมแล้ว ซึ่งถูกจองคิวบน scheduler ที่ priority rrpImmediate เรียบร้อยแล้ว

// FetchRange คือ transport ของคุณ (HTTP Range GET, socket, blob reader):
// มันเขียนไบต์ Size ที่ Offset ลง Store แล้วคืนจำนวนที่มาถึง
function FetchRange(Store: TStream; Offset, Size: UInt64): UInt64; forward;

procedure OpenWhileDownloading(Pdf: TPdfProgressiveDocument; Store: TStream;
  RemoteSize: UInt64);
const
  MaxRounds = 64;
var
  Hints: TPdfDownloadRanges;
  State: TPdfDataAvailability;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Pdf.BeginProgressiveLoad(Store, RemoteSize, False);
  State := pdaNotAvailable;
  for Round := 1 to MaxRounds do
  begin
    State := Pdf.CheckDocumentAvailability(Hints);
    if State <> pdaNotAvailable then
      Break;
    // คำใบ้ถูกจองคิวไว้แล้ว เขียนไบต์ก่อน แล้วค่อยประกาศจบ
    while Pdf.RangeRequests.TryDequeue(Request) do
      Pdf.RangeRequests.CompleteRequest(Request,
        FetchRange(Store, Request.Offset, Request.Size));
  end;
  if State <> pdaAvailable then
    raise EPdfError.Create('The document could not be discovered');
  Pdf.OpenProgressiveDocument;
end;

รายละเอียดสองจุดใน loop นั้นแบกน้ำหนักพอดี เพดานรอบสำคัญเพราะลิงก์ตายทำให้ CheckDocumentAvailability ขอช่วงเดิมไปเรื่อย ๆ ไม่รู้จบ loop ที่ไร้ขอบเขตจึงเปลี่ยนเน็ตพังให้กลายเป็น UI ค้าง ส่วนลำดับสำคัญเพราะ scheduler ทำ serialization กับ state ของตัวเองด้วย critical section แต่ไม่ทำอะไรเลยกับ TStream.Position ของ backing store: thread transport ต้องเขียนไบต์ response ลง stream ก่อนเรียก CompleteRequest เพราะทันทีที่การจบถูกประกาศ PDFium อาจอ่านช่วงนั้น และผู้เขียนพร้อมกันหลายตัวต้องใช้ positioned I/O หรือ lock ของตัวเอง

loop availability ของ TPdfProgressiveDocument ใน PDFium Component: BeginProgressiveLoad สร้าง provider FPDFAvail, CheckDocumentAvailability ส่งคำใบ้ดาวน์โหลดที่เรียงและรวมแล้วซึ่งถูกจองคิวที่ priority rrpImmediate กลับมา, transport เขียนไบต์ลง store ก่อนที่ CompleteRequest จะประกาศแต่ละช่วงให้ PDFium และ loop ถูกจำกัดที่ 64 รอบ เพราะลิงก์ตายจะขอช่วงเดิมไม่หยุด
เขียนไบต์ก่อน แล้วค่อยประกาศจบ request: ทันทีที่การจบถูกประกาศ PDFium อาจอ่านช่วงนั้น และไม่มีอะไรคุ้มครองตำแหน่งของ stream ให้คุณ

ทำไม AvailableByteCount ถึงปฏิเสธที่จะถอยหลัง

AvailableByteCount โตอย่างเดียว และ setter จะโยน EPdfError พร้อมข้อความ "Available byte count cannot move backwards" เมื่อคุณพยายามหดมัน พอ callback IsDataAvail บอก PDFium ไปแล้วว่าช่วงหนึ่งมีอยู่ parser อาจอ่านและ cache object จากมันไปแล้ว การถอนไบต์พวกนั้นทีหลังจึงทำให้คำตอบเรื่อง availability ขัดกับสิ่งที่ PDFium กินไปแล้ว setter ตัวเดิมปฏิเสธค่าที่ใหญ่กว่า LogicalFileSize และโยน "No progressive load is active" เมื่ออยู่นอก session ไบต์ที่คุณถืออยู่ก่อนเริ่มโหลดจึงควรอยู่ใน argument AInitialAvailableByteCount ของ BeginProgressiveLoad ไม่ใช่การกำหนด property ก่อนเวลาอันควร ถ้า store ดาวน์โหลดของคุณเต็มแบบไม่เรียงลำดับ อย่าพยายามบอกมันผ่าน prefix เลย: ประกาศจบช่วงผ่าน scheduler หรือตอบผ่าน OnDataAvailable

PDF ที่ดาวน์โหลดมาครึ่ง ๆ กลาง ๆ เปิดได้จริงเมื่อไร

มีแต่ PDF แบบ linearized (ISO 32000-1 Annex F, ผัง "Fast Web View") เท่านั้นที่เปิดได้ก่อนไฟล์มาถึงครบ PDF แบบไม่ linearized ยังต้องการทุกไบต์ OpenProgressiveDocument เช็ก property Linearization (plnUnknown, plnNotLinearized, plnLinearized) แล้วเส้นทางตามนั้น: ไฟล์ linearized เปิดผ่าน FPDFAvail_GetDocument ทันทีที่ส่วนหน้าแรกกับตารางคำใบ้มาถึง ส่วนไฟล์ที่ไม่ linearized ถูกเปิดผ่าน FPDF_LoadCustomDocument บน record เข้าถึงไฟล์ตัวเดิม และถือว่าอ่านได้เฉพาะทั้งก้อน เส้นทางแบบนี้มีอยู่ด้วยเหตุผลที่จับต้องได้ การเรียก FPDFAvail_GetDocument กับไฟล์ที่ไม่ linearized สามารถคืน handle ไม่ null ที่จำนวนหน้าเป็นศูนย์ เอกสารที่ดูเหมือนเปิดแล้วแต่ว่างเปล่า ใน test suite ของ component เอง fixture linearized 51 หน้าไปถึง pdaAvailable และเปิดได้พร้อม page tree ครบ ทั้งที่ store ดาวน์โหลดแบบเจาะรูยังไม่ครอบคลุมทั้งไฟล์

OpenProgressiveDocument เส้นทางการดาวน์โหลดบางส่วนใน PDFium Component อย่างไร: ไฟล์ linearized เปิดผ่าน FPDFAvail_GetDocument ทันทีที่ส่วนหน้าแรกกับตารางคำใบ้มาถึง, ไฟล์ที่ไม่ linearized ต้องใช้ FPDF_LoadCustomDocument กับทุกไบต์ และ LoadAvailablePage เช็ก availability ของฟอร์มด้วย FPDFAvail_IsFormAvail ก่อนการเช็กหน้า หลีกกับดัก handle ไม่ null ที่หน้าเป็นศูนย์
มีแต่ไฟล์ linearized เท่านั้นที่ได้ตัวตื่นตัวก่อน นอกนั้น FPDFAvail_GetDocument อาจคืนเอกสารหน้าตาเหมือนเปิดแล้วแต่มีศูนย์หน้า ซึ่งเป็นสิ่งที่การเส้นทางแบบนี้ป้องกันพอดี
function WaitForPage(Pdf: TPdfProgressiveDocument; Store: TStream;
  PageNumber: Integer): Boolean;
var
  Hints: TPdfDownloadRanges;
  Request: TPdfRangeRequest;
  Round: Integer;
begin
  Result := False;
  for Round := 1 to 64 do
    case Pdf.LoadAvailablePage(PageNumber, Hints) of
      pdaAvailable:
        Exit(True);   // PageNumber ตอนนี้คือหน้าที่ active
      pdaError:
        Exit(False);
      pdaNotAvailable:
        while Pdf.RangeRequests.TryDequeue(Request) do
          Pdf.RangeRequests.CompleteRequest(Request,
            FetchRange(Store, Request.Offset, Request.Size));
    end;
end;

LoadAvailablePage รับหมายเลขหน้าแบบ 1-based และบังคับลำดับที่ PDFium คาดหวัง: ก่อนเช็กหน้าแรกมันรัน CheckFormAvailability ซึ่งห่อ FPDFAvail_IsFormAvail ไว้ และต่อเมื่อผ่านขั้นนั้นแล้วจึงเรียก FPDFAvail_IsPageAvail ผลเป็น pfaNotPresent เป็นคำตอบปกติของเอกสารที่ไม่มี AcroForm และไม่ขวางอะไรเลย เมื่อหน้าพร้อม LoadAvailablePage จะตั้งมันเป็นหน้าที่ active viewer จึงเรนเดอร์หน้า 1 ของโบรชัวร์ linearized ได้ ขณะที่หน้าที่เหลือยังเดินทางอยู่ FirstAvailablePageNumber บอกว่า dictionary ของ linearization กำหนดหน้าไหนเป็นหน้าแรก ซึ่งแปลงจาก index แบบ zero-based ของ PDFium มาให้แล้ว

CancelProgressiveLoad ปล่อยอะไร และตามลำดับไหน

CancelProgressiveLoad รื้อ session ด้วยสี่ขั้นที่สลับลำดับไม่ได้: ยกเลิก range scheduler, ปิดเอกสาร, ทำลาย availability handle ด้วย FPDFAvail_Destroy แล้วจึง dispose record callback กับคืน stream adapter การยกเลิก scheduler ก่อนจะเด้ง generation counter ของมันขึ้น ทิ้งทุก request ที่รอคิวและที่กำลังบิน แล้วยิง OnCancelRequest ให้ทุกตัวที่บินอยู่ การจบของ transport ที่มาถึงทีหลังจึงพก generation เก่าและ CompleteRequest คืน False โดยไม่แตะอะไรเลย เอกสารต้องปิดก่อนที่ availability handle กับ adapter จะหายไป เพราะ PDFium อาจกลับมาเรียก provider เข้าถึงไฟล์ระหว่างปิดเอกสาร ถ้า adapter หายไปแล้ว callback นั้นจะอ่านหน่วยความจำที่ถูกคืนไปแล้ว

ลำดับ teardown ตายตัวของ CancelProgressiveLoad ใน PDFium Component: ยกเลิก range scheduler ก่อนเพื่อให้การจบล่าช้าชน generation counter ที่เด้งขึ้นแล้วและคืน False, ปิดเอกสารก่อน adapter เข้าถึงไฟล์หายไป, ทำลาย availability handle ด้วย FPDFAvail_Destroy แล้วค่อย dispose record callback กับคืน stream adapter
เมธอด idempotent ตัวเดียวเก็บกวาดทั้งการเริ่มที่ล้มเหลว การยกเลิกโดยผู้ใช้ และ destructor ในทำนองเดียวกัน เมื่อมี worker thread เขียนลง store ความเป็นเจ้าของ stream ยังอยู่กับคุณ
procedure TDownloadForm.FormCreate(Sender: TObject);
begin
  FPdf := TPdfProgressiveDocument.Create(nil);
  // scheduler อายุเท่ากับ FPdf เดินสายครั้งเดียวพอ
  FPdf.RangeRequests.OnCancelRequest := RangeCancelled;
end;

procedure TDownloadForm.RangeCancelled(Sender: TObject; RequestId: UInt64;
  Attempt: Cardinal);
begin
  FTransport.Abort(RequestId);   // โค้ดของคุณ: ปิด socket หรือ request ตัวนั้น
end;

procedure TDownloadForm.CancelButtonClick(Sender: TObject);
begin
  FPdf.CancelProgressiveLoad;
  // ProgressiveLoading = False, Active = False, AvailableByteCount = 0
end;

เมธอดนี้เป็น idempotent และเป็นเส้นทางเก็บกวาดเดียวสำหรับสามสถานการณ์: BeginProgressiveLoad ที่ล้มกลางทางตอนสร้าง, การยกเลิกชัดเจนโดยผู้ใช้ และ destructor BeginProgressiveLoad ยังเรียกมันก่อนเริ่มด้วย รีสตาร์ต object เดิมบน URL ใหม่จึงปลอดภัยโดยไม่ต้อง cancel ชัดเจน มีการตัดสินใจเรื่องความเป็นเจ้าของหนึ่งเรื่องที่คุณต้องทำถูก: ถ้า worker thread เขียนลง backing stream ให้ส่ง AOwnsStream = False แล้วคืน stream เองหลัง worker หยุดแล้ว เพราะถ้าส่งมอบความเป็นเจ้าของไป cancel จะคืน stream ขณะที่การเขียนล่าช้าอาจยังอยู่ระหว่างทาง exception ที่โยนขึ้นใน OnCancelRequest จะถูกกลืนไปเป็นราย request การ transport ที่พังตัวเดียวจึงขวางการยกเลิกที่เหลือไม่ได้

suite lifecycle พิสูจน์ว่าเส้นทาง cancel ไม่รั่วอย่างไร

stress suite ของ lifecycle ใน PDFium Component ซ้อมการดาวน์โหลดแบบถูกขัดจังหวะในสไตล์เครือข่ายทุก cycle แบบผสม แต่ละ cycle เริ่ม progressive load ที่ store ถือไบต์ fixture แค่หนึ่งในสี่ บังคับให้ได้ pdaNotAvailable พร้อมรายการคำใบ้ที่ไม่ว่าง, เรียก CancelProgressiveLoad และ assert ว่า object รายงานทั้ง ProgressiveLoading และ Active เป็นเท็จ จากนั้นรันเส้นทาง streaming เดิมจนจบด้วย availability เต็มรูปแบบ, OpenProgressiveDocument, เรนเดอร์หนึ่งครั้ง แล้วปิด run แบบผสม default ครอบคลุม 100 cycle ที่วัดผล ด้วยการเปิด 600 ครั้ง, เรนเดอร์ 2300 ครั้ง และการยกเลิก progressive 100 ครั้ง หน่วยความจำส่วนตัวที่สุ่มวัดโตขึ้น 8.21 MiB เทียบกับงบ 32 MiB suite นับการยกเลิก progressive แยกจากการยกเลิก callback ของการเรนเดอร์ เพราะการดาวน์โหลดที่ถูกยกเลิกกับ loop เรนเดอร์ที่หยุดก่อนเวลาเป็นเหตุการณ์คนละแบบที่มีเกณฑ์รับต่างกัน

จุดที่เส้นทาง progressive ช่วยไม่ไหว

มีขีดจำกัดอีกไม่กี่ข้อที่ควรรู้ก่อนสร้าง viewer บนฐานนี้ ฟีเจอร์ที่ต้องการไบต์ของไฟล์ต้นฉบับจะปฏิเสธ progressive source ที่ไม่สมบูรณ์แทนที่จะเดา: ReadXmpPacket ล้มเหลวอย่างชัดเจน และการตรวจลายเซ็นรายงาน Indeterminate จนกว่าไฟล์ทั้งไฟล์จะมาครบ การทดสอบ availability ค่า default สมมติ prefix ต่อเนื่อง transport ที่ดึงช่วงแบบไม่เรียงลำดับจึงต้องประกาศจบผ่าน RangeRequests หรือตอบผ่าน OnDataAvailable ไม่งั้น PDFium จะขอไบต์ที่คุณถืออยู่แล้วไปเรื่อย ๆ ไฟล์ที่ไม่ linearized ไม่ได้อะไรเรื่องเวลาถึงหน้าแรกเลย ถ้าความเร็วของ first paint สำคัญ ให้ linearize ไฟล์ฝั่งเซิร์ฟเวอร์ และ CancelProgressiveLoad ไม่ปิด socket ของคุณให้เอง OnCancelRequest คือ hook ที่จุดนั้นเกิดขึ้น

สำหรับเส้นทาง stream adapter ธรรมดาที่โหลดไฟล์ local ที่สมบูรณ์ตามต้องการ ดูการสตรีม PDF ขนาดใหญ่ตามต้องการด้วย PDFium ส่วนการเปิด PDF ที่ซ้อนอยู่ใน buffer ที่ใหญ่กว่า ดูการโหลด byte range สำหรับ PDF ที่ฝังอยู่ การยกเลิกการเรนเดอร์ที่ช้าของหน้าที่โหลดแล้วเป็นกลไกแยกต่างหาก เล่าไว้ในการเรนเดอร์หน้าแบบ progressive ที่ยกเลิกได้ TPdfProgressiveDocument กับ range scheduler ของมัน ship มากับPDFium Component for Delphi and C++Builder