Τεχνικό Άρθρο

Ανάγνωση PDF με memory mapping στο Delphi: συρόμενο παράθυρο

Το PDFlibPas μπορεί να ανοίξει ένα τοπικό PDF μέσω μιας περιορισμένης read-only memory-mapped view: τα LoadFromMappedFile και DAOpenMappedFile κρατούν ακριβώς ένα συρόμενο παράθυρο πάνω στο αρχείο, το κάνουν remap όταν χρειάζεται και εξυπηρετούν κάθε slice αντικειμένου με reads σε absolute offsets. Η Delphi PDF library δεν κρατά ολόκληρη την πηγή στη μνήμη, οπότε η χρήση address space παραμένει σταθερή όσο μεγαλώνει το αρχείο. Ο σχεδιασμός υπάρχει για ένα συγκεκριμένο workload: PDF μεγέθους gigabyte όπου ο parser έχει τελειώσει το αρχικό loading και εξακολουθεί να επιστρέφει στον δίσκο, αντικείμενο προς αντικείμενο και τμήμα stream προς τμήμα stream

Γιατί τα sparse reads παραμένουν ακριβά αφού φορτωθεί το PDF

Το loading ενός PDF δεν σημαίνει ότι τελείωσε η ανάγνωσή του, και σε ένα αρχείο πολλών gigabyte εκεί χάνεται ο χρόνος. Ένας cross-reference table ή cross-reference stream (ISO 32000-1 §7.5.4 και §7.5.8) καταγράφει μόνο πού αρχίζει κάθε indirect object. Τα bytes έρχονται αργότερα, όταν γίνεται render μιας σελίδας, αποκωδικοποιείται ένα font program ή εξάγεται ένα embedded file stream (ISO 32000-1 §7.11.4). Ένα archive 2 GB με δεκάδες χιλιάδες objects γίνεται δεκάδες χιλιάδες μικρά unordered reads, και κανένα από αυτά δεν είναι γνωστό κατά το loading

Η διαδρομή αυτών των reads ήταν παλαιότερα ένα κοινό Seek και μετά Read πάνω σε ένα positional stream, και αποτυγχάνει ταυτόχρονα σε δύο κατευθύνσεις. Κάθε τμήμα πληρώνει το κόστος ενός file read ακόμη κι όταν η σελίδα βρίσκεται ήδη στο cache του λειτουργικού, ενώ ο cursor είναι κοινό mutable state, οπότε ένα local file και η byte-range source πίσω από το progressive PDF range loading με prefetch δεν μπορούσαν να τρέξουν τον ίδιο parser code χωρίς να συγκρούονται για τη θέση. Το PDFlibPas διορθώνει και τα δύο, κάνοντας το absolute-offset reading από optimization contract

Τι εγγυάται το TPDFReadAtStream

Το TPDFReadAtStream εγγυάται read σε absolute offset που ούτε εξαρτάται από ούτε επηρεάζει τον logical stream cursor. Είναι abstract descendant του TStream με ακριβώς μία virtual method, και από αυτόν κληρονομούν και οι δύο cursor-independent sources της library: το TReadOnlyMappedFileStream για local files και το TByteRangeStream για remote sources που εξυπηρετούν ranges. Ο reader των object slices ελέγχει μία φορά αν η source είναι TPDFReadAtStream και επιστρέφει στην παλιά ακολουθία seek-then-read όταν δεν είναι, οπότε ένα συνηθισμένο file stream ή memory stream συνεχίζει να λειτουργεί χωρίς αλλαγές

type
  // Read-only streams των οποίων τα absolute reads αποφεύγουν κοινό Seek και Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Windowed read-only πρόσβαση σε ένα τοπικό αρχείο
  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 ακριβώς εκεί που ήταν, πράγμα που επιτρέπει σε nested parser levels να εκδίδουν reads χωρίς save-and-restore γύρω από κάθε call. Το TReadOnlyMappedFileStream εξακολουθεί να υλοποιεί Read, Seek και Size όπως κάθε άλλο TStream, το Seek περιορίζει τη logical position μέσα στο αρχείο και το Write επιστρέφει πάντα 0 επειδή η source ανοίγει read-only

Άνοιγμα PDF μέσω mapped view στο Delphi

Δύο explicit entry points ανοίγουν mapped source και κανένα δεν αλλάζει τη συμπεριφορά των entry points που ήδη χρησιμοποιείς. Το LoadFromMappedFile φορτώνει και επιλέγει ένα document· το DAOpenMappedFile επιστρέφει Direct Access handle πάνω στο ίδιο αρχείο, που είναι το mode που θέλεις όταν κάνεις merge και split PDF gigabyte μέσω Direct Access. Τα LoadFromFile και DAOpenFile διατηρούν ανέγγιχτα τα file sharing, error και compatibility semantics τους, οπότε τίποτε δεν αλλάζει για callers που δεν κάνουν opt in. Και τα δύο mapped entry points παίρνουν ζητούμενο WindowSize σε bytes και 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]);

    // Το deferred extraction διατρέχει πλέον mapped windows αντί να κάνει seeking
    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 μετατρέπει ένα αθόρυβο fallback σε άμεση και διαγνώσιμη αποτυχία κατά το άνοιγμα. Με Options ίσο με 0 και τα δύο entry points δέχονται fallback σε read-only 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. Το eager mapping είναι όλη η ουσία: αποτυχία του «mapping required» εμφανίζεται στο LoadFromMappedFile, όχι στο πρώτο lazy object read στη μέση μιας rendering job. Χρειάζεται όμως να είναι σαφές πού σταματά η εγγύηση. Ο mapping code γίνεται compile μόνο για Windows targets, και ένα αρχείο μηδενικού μεγέθους δεν επιχειρεί καθόλου mapping, οπότε το PDF_MAPPED_FILE_REQUIRE_MAPPING είναι αίτημα που μπορεί νόμιμα να αποτύχει και όχι portable promise. Αρνητικό WindowSize ή οποιοδήποτε bit στο Options εκτός από τη μία τεκμηριωμένη τιμή απορρίπτεται αμέσως με το ίδιο error 401

Ένα window, με remap σε allocation granularity

Διατηρείται πάντα μόνο ένα view, και αυτό κρατά τη χρήση address space ανεξάρτητη από το μέγεθος του αρχείου. WindowSize 0 επιλέγει 64 MiB· τιμή μικρότερη από το system allocation granularity αυξάνεται σε αυτή· τιμή πάνω από 1 GiB περιορίζεται· και το αποτέλεσμα στρογγυλοποιείται σε ακέραιο αριθμό μονάδων granularity, 65536 bytes στα Windows εκτός αν το GetSystemInfo αναφέρει διαφορετικό dwAllocationGranularity. Όταν ένα read πέσει έξω από το τρέχον view, το PDFlibPas το κάνει unmap, ευθυγραμμίζει το ζητούμενο offset προς τα κάτω σε όριο granularity και κάνει map ένα νέο window εκεί. Το τελευταίο window περιορίζεται στο πραγματικό file size, οπότε το view δεν εκτείνεται ποτέ πέρα από το τέλος του αρχείου

Ένα read μπορεί να διασχίσει οποιονδήποτε αριθμό windows: ο loop αντιγράφει ό,τι μπορεί να δώσει το τρέχον view, κάνει remap και συνεχίζει, ενώ ένα request που περνά το τέλος επιστρέφει short count αντί να αποτύχει. Αυτό που σκόπιμα δεν κάνει το PDFlibPas είναι να σου δώσει pointer μέσα στο view, επειδή το επόμενο cross-window read τον invalidates και κανένας caller δεν θα μπορούσε λογικά να προστατευτεί από αυτό. Τα mapped bytes αντιγράφονται απευθείας σε destination buffers που ανήκουν στον parser, αφαιρώντας το πρόσθετο file input buffer και το switching της θέσης, αλλά η library δεν υπόσχεται zero-copy για το τελικό parser storage. Το windowing της ανάγνωσης συνδυάζεται και με την πλευρά της εγγραφής, αφού το byte-level reference shifting κατά το fast PDF merge κάνει stream τα object bytes προς τα έξω ενώ η mapped source τα κάνει stream προς τα μέσα. Το trade-off του window size είναι προφανές: μικρότερο window κρατά λιγότερο address space και κάνει συχνότερα remap, που συνήθως είναι η σωστή επιλογή μέσα σε 32-bit process

Τι προστατεύει το lock και τι αναφέρει το GetMappedFileInfo

Ένα critical section καλύπτει το mapped view, τον fallback file cursor, τη logical position και τα statistics, και ο διαχωρισμός ανάμεσα στις δύο read methods προκύπτει απευθείας από αυτό. Το ReadAt παίρνει το lock και καλεί τον lock-free internal reader· το Read παίρνει το ίδιο lock, καλεί τον ίδιο internal reader στην τρέχουσα logical position και μετά την προωθεί. Η επαναχρησιμοποίηση της internal function αντί για το public ReadAt αποφεύγει το recursive locking, ενώ το lock που παραμένει ενεργό σε ολόκληρο το copy loop διατηρεί σωστό το remap ενός single-window κάτω από concurrent calls. Μια λεπτομέρεια του Free Pascal αξίζει να γνωρίζεις πριν κάνεις port: η unit Windows του FPC δηλώνει δικό της record με όνομα TCriticalSection, οπότε το field και η κατασκευή του πρέπει να γράφονται ως SyncObjs.TCriticalSection. Το Delphi μεταγλωττίζει πρόθυμα την unqualified μορφή· το FPC την επιλύει σε 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 fallback σε file stream, και είναι το μοναδικό field που αποδεικνύει ότι δεν δημιουργήθηκε ποτέ mapping
  • Το windowSize είναι το effective aligned window και όχι η τιμή που ζήτησες, ενώ το mappedBytes είναι μικρότερο από αυτό στο tail window
  • Το mappedOffset είναι η allocation-aligned αρχή του retained view ή -1 όταν δεν υπάρχει ενεργό view
  • Το readCalls μετρά επιτυχημένα in-range read requests, το bytesRead μετρά bytes που αντιγράφηκαν στους callers και το remapCount περιλαμβάνει το αρχικό view

Στοχευμένα regressions καλύπτουν absolute reads ανάμεσα σε windows, διατήρηση του logical cursor, short reads στο tail, invalid offsets, απορριφθέντα writes, remapping ανάμεσα σε χωριστά windows, deferred extraction ενός ασυμπίεστου attachment 220 KB και statistics που γίνονται invalid μετά το DACloseFile· οι headless suites Win32 και Win64 εντόπισαν από 1467 tests και τα πέρασαν όλα χωρίς ignored, failed, errored ή leaked results. Αν δουλεύεις με PDF gigabyte σε Delphi ή C++Builder και ο profiler συνεχίζει να δείχνει file reads αντί για parsing, τα mapped-file entry points αξίζουν ένα απόγευμα μετρήσεων, και το GetMappedFileInfo θα σου πει αν πήρες πράγματι mapping. Η πλήρης API reference και ένα trial build βρίσκονται στη σελίδα PDFlibPas Delphi PDF library