Techninis straipsnis

Atmintimi susietų PDF skaitymas Delphi: slenkantis langas

PDFlibPas gali atverti vietinį PDF per ribotą tik skaitymo atmintimi susietą vaizdą: LoadFromMappedFile ir DAOpenMappedFile laiko tik vieną slenkantį langą virš failo, prireikus jį susieja iš naujo ir kiekvieną objekto dalį pateikia per absoliučiais poslinkiais paremtą skaitymą. Delphi PDF biblioteka nelaiko viso šaltinio atmintyje, todėl adresų erdvės naudojimas nekinta augant failui. Šis sprendimas skirtas vienam darbo krūviui: gigabaitų PDF, kuriuos parseris jau pradėjo krauti, bet vis dar skaito iš disko objektas po objekto ir srauto fragmentas po srauto fragmento

Kodėl reti skaitymai lieka brangūs net įkėlus PDF?

PDF įkėlimas nereiškia, kad jis jau visiškai perskaitytas, o kelių gigabaitų faile būtent šis skirtumas suėda laiką. Kryžminės nuorodos lentelė arba kryžminių nuorodų srautas (ISO 32000-1 §7.5.4 ir §7.5.8) tik nurodo, kur prasideda kiekvienas netiesioginis objektas. Baitai gaunami vėliau, kai atvaizduojamas puslapis, iškoduojama šrifto programa arba išgaunamas įterptojo failo srautas (ISO 32000-1 §7.11.4). 2 GB archyvas su dešimtimis tūkstančių objektų virsta dešimtimis tūkstančių mažų nesurikiuotų skaitymų, o įkėlimo metu nė vienas iš jų dar nėra žinomas

Anksčiau šie skaitymai ėjo per bendrą Seek, po kurio sekė Read viename poziciniame sraute, ir tai žlunga abiem kryptimis. Kiekvienas fragmentas moka už failo skaitymą net tada, kai puslapis jau yra operacinės sistemos podėlyje, o žymeklis yra bendra keičiama būsena, todėl vietinis failas ir baitų diapazonų šaltinis iš progresyvaus PDF diapazonų įkėlimo su išankstiniu nuskaitymu negalėjo naudoti to paties parserio kodo nesivaržydami dėl pozicijos. PDFlibPas išsprendžia abu dalykus, paversdama skaitymą pagal absoliutų poslinkį ne optimizacija, o sutartimi

Ką garantuoja TPDFReadAtStream?

TPDFReadAtStream garantuoja skaitymą pagal absoliutų poslinkį, kuris nei priklauso nuo loginio srauto žymeklio, nei jį pakeičia. Tai abstraktus TStream palikuonis, turintis lygiai vieną virtualų metodą, ir iš jo paveldimi abu nuo žymeklio nepriklausomi bibliotekos šaltiniai: TReadOnlyMappedFileStream vietiniams failams ir TByteRangeStream nuotoliniams diapazonų šaltiniams. Objektų dalių skaitytuvas vieną kartą patikrina, ar šaltinis yra TPDFReadAtStream, o jei ne, grįžta prie senos seek-then-read sekos, todėl įprastas failo arba atminties srautas ir toliau veikia be pakeitimų

type
  // Tik skaitymo srautai, kurių absoliutus skaitymas išvengia bendro Seek ir Read
  TPDFReadAtStream = class(TStream)
  public
    function ReadAt(Offset: Int64; var Buffer;
      Count: LongInt): LongInt; virtual; abstract;
  end;

  // Languotas tik skaitymo priėjimas prie vieno vietinio failo
  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;

Šis skirtumas svarbesnis, nei rodo signatūra. ReadAt naudoja jam perduotą poslinkį ir palieka Position tiksliai ten, kur buvo, todėl įdėtieji parserio lygiai gali atlikti skaitymus be kiekvieno iškvietimo apgaubimo išsaugojimo ir atkūrimo seka. TReadOnlyMappedFileStream vis tiek realizuoja Read, Seek ir Size kaip bet kuris kitas TStream, Seek apriboja loginę poziciją iki failo, o Write visada grąžina 0, nes šaltinis atidaromas tik skaitymui

PDF atvėrimas per susietą vaizdą Delphi aplinkoje

Du aiškūs entry point atveria susietą šaltinį, ir nė vienas nekeičia jau naudojamų entry point elgsenos. LoadFromMappedFile įkelia ir parenka dokumentą; DAOpenMappedFile grąžina Direct Access rankeną virš to paties failo, būtent tokį režimą verta rinktis jungiant ir skaidant gigabaitų PDF per Direct Access. LoadFromFile ir DAOpenFile išlaiko nepakitusias failų dalijimosi, klaidų ir suderinamumo semantikas, todėl niekas nepasislenka iškvietėjams, kurie funkcijos neįjungia. Abu susieto failo entry point priima pageidaujamą WindowSize baitais ir Options bitų kaukę, o abiem galima perduoti 0

var
  Pdf: TPDFlib;
  Payload: AnsiString;
  Info: WideString;
begin
  Pdf := TPDFlib.Create;
  try
    // WindowSize 0 parenka numatytąjį 64 MiB dydį; susiejimas čia privalomas
    if Pdf.LoadFromMappedFile('archive-2026.pdf', '', 0,
      PDF_MAPPED_FILE_REQUIRE_MAPPING) <> 1 then
      raise Exception.CreateFmt('mapped open refused, LastErrorCode=%d',
        [Pdf.LastErrorCode]);

    // Atidėtas išgavimas dabar vaikšto per susietus langus, o ne atlieka seek
    Payload := Pdf.GetEmbeddedFileContentToString(1);
    if Pdf.GetMappedFileInfo(Info) = 1 then
      Writeln(Info);
  finally
    Pdf.Free;
  end;
end;

Ką iš tikrųjų užtikrina PDF_MAPPED_FILE_REQUIRE_MAPPING?

PDF_MAPPED_FILE_REQUIRE_MAPPING tylų grįžimą paverčia iš karto diagnozuojama nesėkme atvėrimo metu. Kai Options paliktas 0, abu entry point priima tik skaitymui skirtą failo srauto atsarginį kelią: jei platforma neturi susiejimo kodo arba susiejimo iškvietimas nepavyksta, dokumentas vis tiek atveriamas ir kiekvienas skaitymas eina per įprastą failo srautą. Nustačius vėliavą, PDFlibPas priima įvestį tik tada, kai pirmas vaizdas jau sukurtas, ir apie atsisakymą praneša per LastErrorCode 401, o ne įkelia dokumentą, kuris tyliai elgiasi lyg senasis kelias

Windows aplinkoje susietas srautas atveria antrą tik skaitymo rankeną su FILE_SHARE_READ, FILE_SHARE_WRITE ir FILE_SHARE_DELETE bei FILE_FLAG_RANDOM_ACCESS, virš jos sukuria PAGE_READONLY susiejimą ir konstruktoriuje susieja pirmą langą. Skubus susiejimas yra viso sprendimo esmė: klaida „susiejimas privalomas“ išryškėja ties LoadFromMappedFile, o ne per pirmą tingų objekto skaitymą įpusėjus atvaizdavimo užduočiai. Tačiau aiškiai žinokite, kur garantija baigiasi. Susiejimo kodas kompiliuojamas tik Windows taikiniams, o nulio baitų failas apskritai nebando susieti vaizdo, todėl PDF_MAPPED_FILE_REQUIRE_MAPPING yra prašymas, kuris teisėtai gali būti atmestas, o ne nešiojama garantija. Neigiamas WindowSize arba bet kuris kitas nei dokumentuotas bitas Options atmetamas iš karto su ta pačia 401 klaida

Vienas langas, susiejamas iš naujo pagal paskirstymo granuliaciją

Laikomas tik vienas vaizdas, todėl adresų erdvės naudojimas nepriklauso nuo failo dydžio. WindowSize 0 parenka 64 MiB; mažesnė reikšmė pakeliama iki sistemos paskirstymo granuliacijos; didesnė nei 1 GiB apribojama; rezultatas suapvalinamas iki sveiko granuliacijos vienetų skaičiaus, Windows sistemoje tai yra 65536 baitai, nebent GetSystemInfo nurodo kitokį dwAllocationGranularity. Kai skaitymas patenka už dabartinio vaizdo ribų, PDFlibPas jį panaikina, nuleidžia prašomą poslinkį iki granuliacijos ribos ir ten susieja naują langą. Galutinis langas apribojamas fiziniu failo dydžiu, todėl vaizdas niekada nepersikelia už failo pabaigos

Vienas skaitymas gali kirsti bet kiek langų: ciklas nukopijuoja tiek, kiek gali pateikti dabartinis vaizdas, iš naujo susieja langą ir tęsia darbą, o už failo galo nueinantis prašymas grąžina trumpą skaičių, o ne klaidą. PDFlibPas sąmoningai neduoda rodyklės į vaizdą, nes kitas skaitymas per lango ribą ją padaro negaliojančią, o joks iškvietėjas pagrįstai negalėtų nuo to apsisaugoti. Susieti baitai tiesiai kopijuojami į parserio valdomus paskirties buferius, todėl nebereikia papildomo failo įvesties buferio ir pozicijos perjungimo, bet biblioteka neteigia, kad galutinė parserio saugykla veikia be kopijavimo. Lango mechanizmas dera ir su rašymo puse, nes baitų lygio nuorodų poslinkiai greito PDF suliejimo metu siunčia objektų baitus lauk, o susietas šaltinis juos siunčia vidun. Lango dydžio kompromisas akivaizdus: mažesnis langas užima mažiau adresų erdvės ir dažniau susiejamas iš naujo, o tai paprastai yra teisingas pasirinkimas 32 bitų procese

Ką saugo užraktas ir ką pateikia GetMappedFileInfo

Viena kritinė sekcija apima susietą vaizdą, atsarginį failo žymeklį, loginę poziciją ir statistiką, o skirtumas tarp dviejų skaitymo metodų tiesiog iš to išplaukia. ReadAt pasiima užraktą ir iškviečia vidinį skaitytuvą be užrakto; Read pasiima tą patį užraktą, iškviečia tą pačią vidinę funkciją dabartinėje loginėje pozicijoje ir ją pastumia. Pakartotinis vidinės funkcijos naudojimas vietoje viešo ReadAt apsaugo nuo rekursinio užrakinimo, o užrakto laikymas per visą kopijavimo ciklą užtikrina, kad vieno lango susiejimas iš naujo liktų teisingas lygiagrečių iškvietimų metu. Prieš perkeldami kodą žinokite vieną Free Pascal detalę: FPC Windows unitas deklaruoja savo įrašą TCriticalSection, todėl lauką ir jo konstravimą reikia rašyti kaip SyncObjs.TCriticalSection. Delphi nepažymėtą formą sukompiliuoja, o FPC ją susieja su įrašu, neturinčiu Create, Enter ar 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 yra false, kai aktyvus nešiojamas failo srauto atsarginis kelias, ir tai vienintelis laukas, įrodantis, kad vaizdas niekada nebuvo sukurtas
  • windowSize yra efektyvus sulygiuotas langas, o ne prašyta reikšmė, o mappedBytes galiniame lange už jį mažesnis
  • mappedOffset yra paskirstymui sulygiuota išlaikyto vaizdo pradžia arba -1, kai vaizdas šiuo metu neaktyvus
  • readCalls skaičiuoja sėkmingus į failą patenkančius skaitymo prašymus, bytesRead – iškvietėjams nukopijuotus baitus, o remapCount apima ir pradinį vaizdą

Tiksliniai regresijos testai apima absoliučius skaitymus per langų ribas, loginio žymeklio išsaugojimą, trumpus skaitymus failo gale, netinkamus poslinkius, atmestus rašymus, susiejimą iš naujo tarp atskirtų langų, atidėtą 220 KB nesuspaudžiamo priedo išgavimą ir statistiką, kuri tampa negaliojanti po DACloseFile; Win32 ir Win64 begalvės testų programos kiekviena aptiko 1467 testus ir visus juos išlaikė be ignoruotų, nesėkmingų, klaidingų ar nutekėjusių rezultatų. Jei Delphi arba C++Builder dirbate su gigabaitų PDF, o profiliuoklis nuolat rodo failų skaitymą, o ne analizę, susieto failo entry point verta pamatuoti vieną popietę, o GetMappedFileInfo parodys, ar iš tikrųjų gavote susiejimą. Visa API nuoroda ir bandomoji versija pateiktos PDFlibPas Delphi PDF bibliotekos puslapyje