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

อ่าน PDF แบบ Memory-Mapped ใน Delphi ด้วย Sliding Window

PDFlibPas เปิด PDF ในเครื่องผ่าน memory-mapped view แบบอ่านอย่างเดียวที่มีขอบเขตได้ LoadFromMappedFile และ DAOpenMappedFile รักษา sliding window เหนือไฟล์ไว้เพียงหนึ่งบาน remap เมื่อจำเป็น และส่ง slice ของ object ทุกตัวผ่านการอ่านแบบ absolute offset ไลบรารี PDF สำหรับ Delphi ไม่เก็บ source ทั้งหมดไว้ในหน่วยความจำ ดังนั้นการใช้ address space จึงคงที่แม้ไฟล์จะโตขึ้น การออกแบบนี้มีไว้สำหรับ workload เดียว คือ PDF ระดับกิกะไบต์ที่ parser โหลดเสร็จแล้วแต่ยังต้องย้อนกลับไปอ่านดิสก์ทีละ object และทีละ stream fragment

ทำไม sparse read ยังแพงหลังโหลด PDF แล้ว

การโหลด PDF ไม่ได้แปลว่าอ่านจบ และในไฟล์หลายกิกะไบต์ ช่องว่างตรงนี้คือเวลาที่เสียไป cross-reference table หรือ cross-reference stream (ISO 32000-1 §7.5.4 และ §7.5.8) บันทึกเพียงจุดเริ่มต้นของ indirect object แต่ละตัวเท่านั้น byte จะถูกอ่านภายหลังเมื่อ render page, decode font program หรือ extract embedded file stream (ISO 32000-1 §7.11.4) archive ขนาด 2 GB ที่มี object หลายหมื่นตัวจึงกลายเป็น small unordered read หลายหมื่นครั้ง และไม่มีรายการใดรู้ล่วงหน้าตอน load

เดิม read เหล่านี้ผ่าน Seek ที่ใช้ร่วมกันแล้วตามด้วย Read บน positional stream เดียว และมันมีปัญหาสองด้านพร้อมกัน ทุก fragment ต้องจ่ายต้นทุน file read แม้ page จะอยู่ใน operating-system cache แล้ว และ cursor เป็น shared mutable state ดังนั้น local file กับ byte-range source ที่อยู่เบื้องหลัง การโหลด PDF แบบ progressive range พร้อม prefetch จึงใช้ parser code เดียวกันโดยไม่แย่งตำแหน่งกันไม่ได้ PDFlibPas แก้ทั้งสองเรื่องด้วยการยกระดับการอ่านแบบ absolute offset จาก optimization ให้กลายเป็น contract

TPDFReadAtStream รับประกันอะไร

TPDFReadAtStream รับประกันการอ่านที่ absolute offset ซึ่งไม่ขึ้นกับและไม่รบกวน logical stream cursor มันเป็น abstract TStream descendant ที่มี virtual method เพียงหนึ่งตัว และ source ที่ไม่พึ่ง cursor ทั้งสองแบบในไลบรารีก็สืบทอดจากมัน ได้แก่ TReadOnlyMappedFileStream สำหรับ local file และ TByteRangeStream สำหรับ remote source ที่ให้บริการเป็น range ตัวอ่าน object-slice จะตรวจเพียงครั้งเดียวว่า source เป็น TPDFReadAtStream หรือไม่ และจะ fallback ไปใช้ลำดับ seek-then-read แบบเดิมถ้าไม่ใช่ ดังนั้น ordinary file stream หรือ memory stream ยังทำงานเหมือนเดิม

type
  // stream แบบอ่านอย่างเดียวที่อ่าน absolute ได้โดยไม่ต้องใช้ Seek กับ Read ร่วมกัน
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // เข้าถึง local file แบบอ่านอย่างเดียวผ่าน window
  TReadOnlyMappedFileStream = class(TPDFReadAtStream)
  private
    FMemoryMapped: Boolean;
  public
    constructor Create(const FileName: WideString; WindowSize: Int64 = 0);
    function GetStats: TPDFMappedFileStats;
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; override;
    property MemoryMapped: Boolean read FMemoryMapped;
  end;

ความแตกต่างนี้สำคัญกว่าที่ signature บอกไว้ ReadAt ใช้ offset ที่ได้รับมาและปล่อย Position ไว้ตรงเดิม นั่นทำให้ parser level ที่ซ้อนกันออก read ได้โดยไม่ต้องทำ save-and-restore รอบทุก call TReadOnlyMappedFileStream ยัง implement Read, Seek และ Size เหมือน TStream อื่น ๆ Seek จะ clamp logical position ให้อยู่ในไฟล์ และ Write คืนค่า 0 เสมอเพราะ source เปิดแบบอ่านอย่างเดียว

เปิด PDF ผ่าน mapped view ใน Delphi

มี entry point แบบ explicit สองตัวสำหรับเปิด mapped source และไม่มีตัวใดเปลี่ยนพฤติกรรมของ entry point ที่คุณใช้อยู่แล้ว LoadFromMappedFile โหลดและเลือก document ส่วน DAOpenMappedFile คืน Direct Access handle เหนือไฟล์เดียวกัน ซึ่งเป็น mode ที่เหมาะเมื่อ merge และ split PDF ระดับกิกะไบต์ผ่าน Direct Access LoadFromFile และ DAOpenFile ยังคง file sharing, error และ compatibility semantics เดิม ดังนั้น caller ที่ไม่ opt in จะไม่เห็นอะไรเปลี่ยน entry point แบบ mapped ทั้งสองตัวรับ WindowSize ที่ร้องขอเป็น byte และ bitmask Options และทั้งคู่รับค่า 0 ได้สำหรับทั้งสองพารามิเตอร์

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 เลือกค่าเริ่มต้น 64 MiB และบังคับให้ต้อง mapping
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // การ extract แบบ deferred จะเดินผ่าน mapped window แทนการ seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

PDF_MAPPED_FILE_REQUIRE_MAPPING บังคับอะไรจริง

PDF_MAPPED_FILE_REQUIRE_MAPPING เปลี่ยน silent fallback ให้เป็น failure ที่ตรวจวินิจฉัยได้ทันทีตอนเปิด เมื่อปล่อย Options เป็น 0 entry point ทั้งสองตัวยอมรับ fallback เป็น file stream แบบอ่านอย่างเดียว หาก platform ไม่มี mapping code หรือ mapping call ล้มเหลว document ก็ยังเปิดได้และทุก read จะผ่าน file stream ปกติ แต่เมื่อเปิด flag นี้ PDFlibPas จะรับ input ก็ต่อเมื่อสร้าง view แรกสำเร็จ และรายงานการปฏิเสธผ่าน LastErrorCode 401 แทนที่จะโหลด document ที่เงียบ ๆ แล้วทำงานเหมือนเส้นทางเดิมทุกอย่าง

บน Windows mapped stream จะเปิด read-only handle ตัวที่สองด้วย FILE_SHARE_READ, FILE_SHARE_WRITE และ FILE_SHARE_DELETE พร้อม FILE_FLAG_RANDOM_ACCESS สร้าง mapping แบบ PAGE_READONLY แล้ว map window แรกใน constructor การ map ตั้งแต่ต้นคือหัวใจของเรื่องนี้ เพราะ failure แบบ "mapping required" จะโผล่ที่ LoadFromMappedFile ไม่ใช่ตอน lazy object read ครั้งแรกกลางงาน render ต้องเข้าใจขอบเขตของ guarantee ด้วย mapping code compile เฉพาะ Windows target และไฟล์ขนาดศูนย์จะไม่พยายาม map เลย ดังนั้น PDF_MAPPED_FILE_REQUIRE_MAPPING คือคำขอที่ล้มเหลวได้อย่างถูกต้อง ไม่ใช่คำรับรองที่ portable ค่า WindowSize ติดลบ หรือ bit ใด ๆ ใน Options นอกเหนือจากค่าที่ระบุไว้หนึ่งค่า จะถูกปฏิเสธทันทีด้วย error 401 เดิม

หน้าต่างเดียว remap ตาม allocation granularity

มีการเก็บ view ไว้เพียงหนึ่งบานเสมอ และนี่คือสิ่งที่ทำให้การใช้ address space ไม่ขึ้นกับขนาดไฟล์ WindowSize ที่เป็น 0 จะเลือก 64 MiB ค่าที่ต่ำกว่า system allocation granularity จะถูกยกขึ้นมาเท่าค่านั้น ค่าที่สูงกว่า 1 GiB จะถูกจำกัด และผลลัพธ์จะถูกปัดขึ้นเป็นจำนวนเต็มของ granularity unit คือ 65536 byte บน Windows เว้นแต่ GetSystemInfo จะรายงาน dwAllocationGranularity อื่น เมื่อ read ตกอยู่นอก view ปัจจุบัน PDFlibPas จะ unmap มัน align offset ที่ร้องขอลงไปยัง granularity boundary แล้ว map window ใหม่ตรงนั้น window สุดท้ายจะถูก clamp ตามขนาดไฟล์จริง ดังนั้น view จะไม่ยื่นเลยจุดจบของไฟล์

read ครั้งเดียวอาจข้ามกี่ window ก็ได้ loop จะ copy ส่วนที่ view ปัจจุบันให้ได้ remap แล้วทำต่อ และ request ที่เลยท้ายไฟล์จะคืน short count แทนการล้มเหลว สิ่งที่ PDFlibPas ตั้งใจไม่ทำคือส่ง pointer เข้าไปใน view ให้คุณ เพราะ read ข้าม window ครั้งต่อไปจะทำให้ pointer นั้นใช้ไม่ได้ และไม่มี caller คนใดป้องกันเรื่องนี้ได้อย่างสมเหตุสมผล byte ที่ map แล้วถูก copy ตรงเข้า parser-owned destination buffer จึงตัดทั้ง file input buffer เพิ่มเติมและการสลับ position ออกไป แต่ไลบรารีไม่ได้อ้างว่า storage สุดท้ายของ parser เป็น zero-copy การทำ window ฝั่ง read ยังประกอบกับฝั่ง write ได้ด้วย เพราะ การเลื่อน reference แบบ byte-level ระหว่าง fast PDF merge stream object byte ออกในขณะที่ mapped source stream byte เข้า trade-off ของ window size ตรงไปตรงมา window เล็กใช้ address space น้อยแต่ remap บ่อยกว่า ซึ่งมักเหมาะกับ process แบบ 32-bit

lock ป้องกันอะไร และ GetMappedFileInfo รายงานอะไร

critical section เดียวครอบคลุม mapped view, fallback file cursor, logical position และ statistics ส่วนการแยก read method สองแบบก็เกิดจากขอบเขตนี้โดยตรง ReadAt จะถือ lock แล้วเรียก internal reader ที่ไม่ lock ส่วน Read จะถือ lock เดียวกัน เรียก internal reader ที่ current logical position แล้วเลื่อน position ต่อ การ reuse internal function แทน public ReadAt ทำให้ไม่เกิด recursive locking และการถือ lock ตลอด copy loop ทำให้ remap แบบ one-window ถูกต้องเมื่อมี concurrent call รายละเอียด Free Pascal อย่างหนึ่งที่ควรรู้ก่อน port คือ FPC Windows unit ประกาศ record ชื่อ TCriticalSection ของตัวเอง ดังนั้น field และการสร้างต้องเขียนเป็น SyncObjs.TCriticalSection Delphi compile รูปแบบที่ไม่ระบุ unit ได้อย่างสบาย แต่ FPC resolve ไปเป็น record ที่ไม่มี Create, Enter หรือ Leave

var
  Pdf: TPDFlib;
  Handle, PageRef: Integer;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    Handle := Pdf.DAOpenMappedFile('archive-2026.pdf', '',
      16 * 1024 * 1024, PDF_MAPPED_FILE_REQUIRE_MAPPING);
    if Handle = 0 then
      Exit;
    try
      PageRef := Pdf.DAFindPage(Handle, 1);
      Writeln(Pdf.DAExtractPageText(Handle, PageRef, 0));

      // {"memoryMapped":true,"fileSize":...,"remapCount":...}
      if Pdf.DAGetMappedFileInfo(Handle, Info) = 1 then
        Writeln(Info);
    finally
      Pdf.DACloseFile(Handle);
    end;
  finally
    Pdf.Free;
  end;
end;
  • memoryMapped เป็น false เมื่อ portable file-stream fallback ทำงาน และเป็น field เดียวที่ยืนยันได้ว่าไม่เคยสร้าง mapping
  • windowSize คือ window ที่ align แล้วจริง ๆ ไม่ใช่ค่าที่ร้องขอ และ mappedBytes จะเล็กกว่าค่านี้ใน tail window
  • mappedOffset คือจุดเริ่มต้นของ view ที่เก็บอยู่หลัง align ตาม allocation หรือเป็น -1 เมื่อไม่มี view ใช้งานอยู่
  • readCalls นับ request ที่อ่านสำเร็จและอยู่ในช่วงไฟล์ bytesRead นับ byte ที่ copy ให้ caller และ remapCount รวม view แรกด้วย

regression ที่เจาะจงครอบคลุม absolute read ข้าม window, การรักษา logical cursor, short read ที่ท้ายไฟล์, offset ที่ไม่ถูกต้อง, write ที่ถูกปฏิเสธ, การ remap ระหว่าง window ที่แยกจากกัน, deferred extraction ของ attachment ที่บีบอัดไม่ได้ขนาด 220 KB และ statistics ที่กลายเป็น invalid หลัง DACloseFile โดย Win32 และ Win64 headless suite ต่างค้นพบ 1467 test และผ่านทั้งหมดโดยไม่มี ignored, failed, errored หรือ leaked result หากคุณทำงานกับ PDF ระดับกิกะไบต์ใน Delphi หรือ C++Builder และ profiler ยังคงชี้ไปที่ file read แทน parsing mapped-file entry point เหล่านี้คุ้มค่ากับการวัดสักบ่ายหนึ่ง และ GetMappedFileInfo จะบอกว่าคุณได้ mapping จริงหรือไม่ API reference ฉบับเต็มและ trial build อยู่ที่หน้า ไลบรารี PDF สำหรับ Delphi ของ PDFlibPas