Technisch artikel

PDFium Byte-Range Loading voor Ingebedde PDF's in Delphi

Met overflowcontrole uit evalueert 2000000000 + 2000000000 naar -294.967.296 als beide operanden Delphi Integer-waarden zijn. Dat getal houdt op triviaheden te zijn op het moment dat een containerparser een offset en een lengte uit een recordheader leest en ze optelt om te zien of een ingebedde PDF in de buffer past. De som slaat negatief om, de vergelijking tegen Length(Data) passeert, en de volgende regel neemt het adres van een byte van ruwweg twee gigabyte voorbij het einde van de array. Een PDF uit het midden van een grotere buffer laden vraagt een grenscontrole die nooit optelt, en een duidelijk antwoord op de vraag wie de bytes bezit zodra het document open is

Dit artikel behandelt documenten die al in het RAM staan, op een bekende offset binnen iets anders. Documenten die te groot zijn om in het geheugen te houden zijn een ander probleem, opgelost door op dem-and blokken van schijf te halen zoals beschreven in grote PDF's on-demand streamen met PDFium VCL. De laatste sectie trekt de grens

Een ingebedde PDF op een bekende offset

Een mailopslag bewaart berichtteksten en bijlagen in één record. Een archiefcontainer plakt een manifest, een paar images en een PDF aaneen. Een custom wire-protocol kadert een document in achter een header met lengteprefix. Stel dat het record 40 MB is, de PDF begint op byte 1.182.336 en 312 KB doorloopt. De gewoontecode roept Copy(Data, Index, Count) aan om een plak te snijden, en geeft die plak dan aan een lader die hem opnieuw kopieert in zijn eigen buffer. Container meegerekend bestaat dezelfde 312 KB nu drie keer, en een mailboxscan herhaalt dat voor elk bericht

PDFium Component adresseert het venster direct. TPdf declareert LoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean = True), waarbij Index een nulgebaseerde bytepositie is en Count een bytelengte. De methode valideert beide getallen, berekent één pointer en delegeert naar de pointeroverload LoadDocument(Data: Pointer; Size: NativeUInt; Buffered: Boolean), die het document opent via FPDF_LoadMemDocument64. De hele-array-vorm LoadDocument(const Data: TBytes; Buffered: Boolean) is een eenregelige aanroep naar de byte-range-vorm met 0 en Length(Data), dus elke TBytes-load passeert dezelfde validatie

Vergelijkingsdiagram dat het slice-then-load dubbelkopie-pad voor een ingebedde PDF binnen een container in Delphi toont, versus de PDFium Component byte-range LoadDocument-overload die het venster ter plekke adresseert
Met Copy snijden en dan laden kopieert hetzelfde 312 KB-venster twee keer; de byte-range-aanroep adresseert hem ter plekke en Buffered bepaalt of er überhaupt iets wordt gekopieerd

In code verdwijnt de plak gewoon. LocateEmbeddedPdf staat voor je eigen containerparser

uses
  System.SysUtils, System.IOUtils, PDFium;

procedure PreviewEmbedded(const RecordFile: string);
var
  Frame: TBytes;           // hele containerrecord, tientallen megabytes
  Offset, Size: Integer;
  Pdf: TPdf;
begin
  Frame := TFile.ReadAllBytes(RecordFile);
  LocateEmbeddedPdf(Frame, Offset, Size);   // je parser, geen deel van het component

  Pdf := TPdf.Create(nil);
  try
    // geen Copy(Frame, Offset, Size): het venster wordt ter plekke geadresseerd
    Pdf.LoadDocument(Frame, Offset, Size);  // Buffered staat standaard op True
    Frame := nil;  // prima hier: alleen het PDF-venster werd gekopieerd
    Writeln('Pages: ', Pdf.PageCount);
  finally
    Pdf.Free;      // sluit het document
  end;
end;

Als de globale EnableV8Engine nog False is en de PDFium-library nog niet is geladen, scannen de geheugenladers de binnenkomende bytes op XFA-markers en zetten EnableV8Engine aan wanneer ze er vinden, zodat de V8-capabele DLL wordt geladen. Bij de byte-range-aanroep ziet die scan alleen het venster. Een manifest of een tweede document elders in hetzelfde record kan hem niet triggeren

Waarom laat Index plus Count de grenscontrole omslaan?

Omdat Index en Count allebei 32-bit signed Integer-waarden zijn, en het optellen van twee grote positieve waarden niet betrouwbaar een groot positief resultaat oplevert. Waar de offset en lengte uit een bestandsheader komen, bepalen een aanvaller of een gecorrupt record allebei. Dezelfde valkuil loert in je eigen containerparser, die die getallen eerst berekent

// een helper voor je eigen containerparser (voorgesteld, geen deel van het component)
function IsValidWindow(const Data: TBytes; Index, Count: Integer): Boolean;
begin
  // FOUT: Index + Count wordt geëvalueerd als Integer en kan negatief omslaan
  //   Result := (Index >= 0) and (Index + Count <= Length(Data));

  // GOED: verwerp tekens, begrens Index op zichzelf, vergelijk daarna Count
  // met het restant; de enige rekenkunde is een aftrekking die niet kan omslaan
  Result := (Index >= 0) and (Count >= 0) and
            (Index <= Length(Data)) and
            (Count <= Length(Data) - Index);
end;

Werk het falende geval door. Met Index = 2000000000 en Count = 2000000000 is de ware som vier miljard, maar 32-bit signed rekenkunde slaat hem op als -294.967.296. Die waarde zit ruim onder elke Length(Data), dus de verkeerde controle passeert en @Data[Index] wijst ver buiten de array, met een lengte van twee gigabyte eraan vast. Een crash is de goede afloop. De slechte is PDFium die het procesgeheugen dat daar toevallig woont gaat parsen

De correcte volgorde telt nooit op. Negatieve waarden worden geweigerd voordat er iets wordt geïndexeerd. Daarna wordt Index op zichzelf begrensd tegen Length(Data), wat garandeert dat Length(Data) - Index een niet-negatieve Integer is, en pas dan wordt Count vergeleken met dat restant. Delphi evalueert and standaard met short-circuit-logica ({$B-}), dus elke latere term draait pas als de eerdere kloppen. Leun niet op {$Q+} als vangnet: releasebuilds worden routineus verzonden met overflowcontrole uit, en met hem aan heb je alleen een geheugenveiligheidsgat ingeruild voor een EIntOverflow die uit het midden van een validatieroutine ontsnapt

Stroomdiagram dat de omslaande Index-plus-Count grenscontrole afzet tegen de alleen-subtractie-validatievolgorde die PDFium byte-range-lading geheugenveilig houdt in Delphi
Eerst optellen slaat voorbij MaxInt om en verslaat de bewaking; tekens verwerpen en termen begrenzen vóór één omslagvrije aftrekking houdt elke tussenwaarde representeerbaar

De byte-range LoadDocument draait dezelfde vier tests in dezelfde volgorde en geeft EPdfError met een melding die de gefaalde regel benoemt: "PDF byte range index cannot be negative", "PDF byte range count cannot be negative", "PDF byte range index exceeds data length" en "PDF byte range exceeds data length". Je kunt elk ervan reproduceren met een array van vier bytes. Index = 5, Count = 0 triggert de derde melding, Index = 2, Count = 3 de vierde, en na elke weigering is Pdf.Active nog False. Dezelfde behandeling van onbetrouwbare lengtes over de native grens wordt breder behandeld in het hardenen van de PDFium VCL ABI en geheugenveiligheid in Delphi

Een venster met lengte nul is nog steeds een legale aanroep

Een leeg venster geeft een nil-pointer door, want @Data[Index] is geen legale expressie voor elke Index die de validatie accepteert. Index = Length(Data) met Count = 0 is een welgevormd leeg venster aan de staart van de buffer, en een lege TBytes geeft Index = 0 op een array die helemaal geen element nul heeft. Het adres nemen leest in beide gevallen voorbij het einde of dereferentieert een nil dynamische array, en met range checking aan geeft Delphi ERangeError voordat de lader überhaupt draait

Dus de byte-range-overload vertakt: Count = 0 levert nil op, elke andere count levert @Data[Index] op. De pointeroverload accepteert nil alleen samen met een grootte van nul, PDFium weigert dan een lege buffer te openen, en de aanroep eindigt in een gewone EPdfError met de last-error-tekst van PDFium, of "Cannot load PDF document" wanneer PDFium niets meldt. Een header die een bijlage van nul bytes claimt krijgt dezelfde opvangbare exception als elke andere slechte invoer. Hem in je parser weigeren, met het recordnummer in de melding, leest in een supportlog nog steeds beter

Buffered bepaalt wie de bytes bezit

Buffered is hier de enige parameter wiens gevolgen de aanroep overleven. Met Buffered = True kopieert het component het geselecteerde venster, en alleen het venster, naar zijn eigen buffer voordat hij laadt. In het voorbeeld van 40 MB worden 312 KB gekopieerd en blijft de container met rust. Zodra LoadDocument terugkeert, mag je de container onmiddellijk vrijgeven, hergebruiken of overschrijven

Met Buffered = False gaat @Data[Index] rechtstreeks naar FPDF_LoadMemDocument64, en PDFium blijft via die pointer lezen zolang het document open is. De hele onderliggende TBytes wordt geleend. Ze moet in leven en ongewijzigd blijven totdat je Active := False zet of de TPdf vrijgeeft. De hele array telt, niet alleen het venster: een Delphi dynamische array is reference counted als één blok, dus de laatste referentie laten vallen waar dan ook in je code geeft het geheugen vrij dat PDFium nog leest. SetLength is net zo fataal. Een reallocatie kan het blok verplaatsen

Twee Delphi-details maken dit makkelijker fout te doen dan het eruitziet. Dynamische arrays hebben geen copy-on-write, in tegenstelling tot strings, dus Other := FFrame; Other[0] := 0; schrijft in de bytes die PDFium op dat moment parsen. En een ontvangbuffer die je netwerkcode hergebruikt voor het volgende frame wordt onder het open document overschreven. Een functieresultaat rechtstreeks doorgeven, als in Pdf.LoadDocument(ReadRecord(Id), Offset, Size, False), laat de array vastgehouden door alleen een verborgen tijdelijke die sterft met de aanroepende routine, terwijl het document blijft bestaan

Tijdlijndiagram van de twee Buffered-modi bij het laden van een ingebedde PDF vanuit een byte range in Delphi: alleen het PDF-venster kopiëren en de container onmiddellijk vrijgeven, versus de hele backing TBytes lenen tot het document is gesloten
Buffered = True kopieert alleen het venster zodat de container wegwerpbaar is, terwijl Buffered = False de hele backing array geleend laat tot het document is gesloten

Houd Buffered = True voor alles wat een thread passeert, achter een herbruikbare buffer zit, of de routine die hem laadt overleeft. De extra kost is één kopie van de eigen grootte van de PDF, en die koopt de vrijheid om een record van 40 MB los te laten op het moment dat de aanroep terugkeert. Lenen verdient zijn plek alleen wanneer de container al een langlevend veld is dat je toch de hele sessie vasthoudt, zoals een viewer die een archiefrecord openhoudt terwijl de gebruiker door de bijlage bladert. Maak in dat geval het eigendom expliciet in één class

type
  TFrameSession = class
  private
    FFrame: TBytes;   // is eigenaar van de onderliggende opslag zolang FPdf een document open heeft
    FPdf: TPdf;
  public
    constructor Create(const AFrame: TBytes);
    destructor Destroy; override;
    procedure OpenEmbedded(Offset, Size: Integer);
  end;

constructor TFrameSession.Create(const AFrame: TBytes);
begin
  inherited Create;
  FFrame := AFrame;   // deelt de array; niemand anders mag er nu naar schrijven
  FPdf := TPdf.Create(nil);
end;

procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
  FPdf.Active := False;                            // sluit eerst een eventueel eerder venster
  FPdf.LoadDocument(FFrame, Offset, Size, False);  // FFrame moet dit document overleven
end;

destructor TFrameSession.Destroy;
begin
  FPdf.Active := False;  // 1. geef de lening vrij
  FPdf.Free;
  FFrame := nil;         // 2. pas nu mag de opslag weg
  inherited;
end;

De constructor deelt de array van de aanroeper in plaats van hem te kopiëren, dus de aanroeper moet ermee stoppen er naar te schrijven. Kun je dat niet beloven, geef dan Copy(AFrame) door aan de constructor en je bent terug bij één kopie, eenmaal per sessie in plaats van eenmaal per load

Controleren dat het venster op %PDF- begint

Een venster dat een paar bytes ernaast zit is de subtiele mislukking, want het document kan nog gewoon openen. ISO 32000-1 §7.5.4 definieert elke cross-reference-entry als een byteoffset vanaf het begin van het bestand, en §7.5.2 zet de %PDF--header aan het begin. Telt je parser een prefix verkeerd en begint het venster acht bytes te vroeg, dan zitten die offsets er acht bytes naast. PDFium zoekt de header binnen de eerste 1024 bytes en compenseert voorlopende rommel; een venster dat te laat begint of te vroeg eindigt kan hem ertoe drijven de cross-reference-tabel te herbouwen uit de objectmarkers die hij vindt. Hoe dan ook oogt het paginaaantal kloppend. De offset in je parser is nog steeds fout, en de volgende lezer van dat record is misschien minder vergevingsgezind

De overload LoadDocument(const Data: TBytes; Index, Count: Integer; const Options: TPdfLoadOptions; out Report: TPdfLoadReport; Buffered: Boolean = True) maakt dat zichtbaar. Report.HeaderOffset is waar %PDF- werd gevonden binnen de eerste 1024 bytes van het venster, of -1 wanneer hij niet werd gevonden. Report.UsedRecovery is True wanneer PDFium de cross-reference-tabel niet als geldig accepteerde. Alle offsets in het rapport tellen vanaf de eerste byte van het venster, niet vanaf de container. Een Count die te kort is snijdt de afsluitende %%EOF af, en TPdfLoadOptions.Default(plmStrict) zet RequireFinalEndOfFileMarker aan, dus de strikte modus weigert dat venster ronduit

var
  Options: TPdfLoadOptions;
  Report: TPdfLoadReport;
begin
  Options := TPdfLoadOptions.Default;   // plmCompatible
  Pdf.LoadDocument(Frame, Offset, Size, Options, Report, True);

  // LogWarning is je eigen logroutine
  if Report.HeaderOffset > 0 then
    LogWarning(Format('Window starts %d bytes before %%PDF-, check Offset',
      [Report.HeaderOffset]))
  else if Report.HeaderOffset < 0 then
    LogWarning('No %PDF- header in the first 1024 bytes of the window');

  if Report.UsedRecovery then
    LogWarning('Cross-reference table was rebuilt, check Offset and Size');
end;

De audit achter die velden heeft een kost die ertoe doet bij geleende loads. Past het venster binnen Options.AuditByteLimit (standaard 256 MB), dan leest de audit een tijdelijke kopie van het venster, dus Buffered = False gecombineerd met een rapport is tijdens de load niet langer kopievrij. AuditByteLimit op 0 zetten slaat de audit en de kopie over en laat HeaderOffset op -1. Draai de geauditeerde vorm terwijl je de containerparser ontwikkelt en tegen nieuwe bronnen van records; schakel over op de kale overload zodra de offsets worden vertrouwd. De rest van het rapport wordt behandeld in PDF-laadfouten opvangen met een PDFium load report

Wanneer een byte range het verkeerde gereedschap is

De byte-range-overload veronderstelt dat de hele container al in het geheugen staat, en omdat Count een Integer is, kan één venster niet voorbij MaxInt bytes gaan. Een archief van 6 GB op schijf, of een socket die je niet kunt terugspoelen, past niet in die vorm, en alles in TBytes lezen alleen om een venster erin te adressen verslaat het doel. Voor die gevallen laat LoadCustomDocument(AStream: TStream; AOwnsStream: Boolean) PDFium blokken uit een seekable stream op dem-and halen. Een kleine TStream-afstammeling van eigen makkelij die de startoffset van de PDF bij elke Seek optelt, presenteert het ingebedde document alsof hij op positie nul begint, zoals het artikel over on-demand streaming laat zien

De andere mismatch is content die nog geen kale PDF is. Zijn de ingebedde bytes gecomprimeerd, door de container versleuteld, of gewikkeld in een andere codering, dan is een getransformeerde kopie onvermijdelijk, en die nieuwe array laden met de default Buffered = True is het eerlijke antwoord. De byte range betaalt zich uit in één vorm: aaneengesloten, ongewijzigde PDF-bytes, al resident, op een offset die je parser heeft gevalideerd zonder op te tellen

De byte-range-aanroep staat naast bestand-, stream-, ruwe-pointer- en on-demand-lading in hetzelfde TPdf-component. De productpagina van PDFium Component somt de ondersteunde Delphi- en C++Builder-versies, licentieopties en de rest van de laad-API op