Articol tehnic

Fluxuri de obiecte PDF și fluxuri xref în Delphi

Fluxurile de obiecte PDF 1.5 comprimă multe obiecte indirecte mici într-un singur container comprimat Flate, iar losLab PDF Library le emite la o salvare completă prin flagul PackObjectStreams. Câștigul este real: sute de dicționare de pagină, font și adnotare, fiecare costând zeci de octeți necomprimați, se restrâng într-un mănunchi de blob-uri comprimate. Costul este că fiecare obiect împachetat are acum nevoie de un flux de referințe încrucișate care să îl descrie

A doua jumătate este acolo unde se rup implementările. Construirea unui container /ObjStm ține de aritmetică; a învăța mecanismul de referințe încrucișate să indice în interiorul lui este o reproiectare. Un writer care produce un container perfect valid și apoi descrie membrii lui cu deplasamente obișnuite de tip 1 a produs un fișier pe care Acrobat îl va deschide exact cât să declare că este corupt. Cele două funcționalități sunt, de fapt, una singură, iar acest articol acoperă partea de scriere a ambelor, așa cum sunt definite în ISO 32000-1 §7.5.7 și §7.5.8

Ce conține de fapt un container ObjStm

Un flux de obiecte este un flux ale cărui octeți decodați formează două regiuni concatenate, iar ISO 32000-1 §7.5.7 oferă dicționarului exact trei chei relevante pentru construcție. /Type /ObjStm îl identifică, /N dă numărul de membri, iar /First dă lungimea în octeți a regiunii de antet — altfel spus, deplasamentul la care începe corpul. Antetul este format din perechi număr-de-obiect/deplasament separate prin spații; corpul este format din membrii serializați unul după altul, fiecare deplasament fiind măsurat de la începutul corpului, nu de la începutul payload-ului decodat. Citirea unui container complet decodat face lucrul evident: mai jos, /First este 14 pentru că cele trei linii de antet ocupă paisprezece octeți, iar obiectul 7 se află la 55 de octeți în corp pentru că obiectul 4 s-a serializat la 54 de caractere plus un separator

// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
//                        /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]

Două reguli de apartenență sunt absolute și ambele provin direct din §7.5.7. Un obiect de tip flux nu poate fi niciodată membru, pentru că un flux poartă octeți bruți care ar trebui imbricați în interiorul altui flux. Iar un membru trebuie să fie o valoare de obiect completă, niciodată o simplă referință indirectă — un obiect comprimat care este doar 5 0 R creează o indirecție pe care cititorul nu o poate rezolva fără să știe deja spre ce indică. losLab PDF Library filtrează ambele cazuri în etapa de colectare a candidaților, împreună cu dicționarul de criptare și obiectul 0, apoi împachetează tot ce rămâne în grupuri de câte 200 per container. Această limită este o decizie legată de accesul aleatoriu, nu o limită impusă de specificație: un cititor care are nevoie de un singur membru trebuie să decomprime întregul container, așa că containerele supradimensionate fac căutările mici costisitoare

De ce membrii ObjStm trebuie să folosească intrări xref de tip 2?

Pentru că un obiect împachetat nu are niciun deplasament de fișier de înregistrat. ISO 32000-1 §7.5.8 răspunde la asta cu trei tipuri de intrări într-un flux binar de referințe încrucișate: tipul 0 pentru obiecte libere, tipul 1 pentru obiecte obișnuite în uz, stocate la un deplasament în octeți, și tipul 2 pentru obiecte comprimate, ale căror două câmpuri de date rețin numărul obiectului-container și indexul membrului în interiorul lui. Nu există nicio modalitate de a exprima un obiect împachetat în tabela clasică xref în text simplu, motiv pentru care PDF 1.5 a introdus ambele funcționalități împreună

Ordinea care rezultă de aici pune în dificultate aproape orice primă implementare, inclusiv pe a noastră. Obiectele obișnuite primesc intrări de tip 1. Containerele /ObjStm primesc și ele intrări de tip 1, pentru că un container este un obiect flux indirect perfect normal, scris la un deplasament real. Doar membrii primesc intrări de tip 2. Iar fluxul de referințe încrucișate este el însuși un obiect indirect în fișier, așa că are nevoie de propria intrare de tip 1, care indică spre deplasamentul la care tocmai a fost scris — același deplasament înregistrat de startxref. O versiune timpurie a writer-ului nostru excludea numerele obiectelor-container din bucla de scriere, în loc să excludă membrii, iar rezultatul era un fișier cu un flux de referințe încrucișate și niciun flux de obiecte: coerent structural, dar gol semantic, respins mai departe în lanț. Valoarea /Size ascunde o eroare corespunzătoare de tip off-by-one, deoarece reprezintă cel mai mare număr de obiect plus unu, iar fluxul de referințe încrucișate este alocat ca având cel mai mare număr de obiect, deci trebuie numărat și el

Dimensionarea tabloului /W: de ce patru octeți nu sunt suficienți

Tabloul /W declară lățimea în octeți a fiecăruia dintre cele trei câmpuri, iar losLab PDF Library îl scrie ca /W [1 Field2 Field3], cu câmpul 1 fixat la un octet pentru codul de tip și câmpul 3 fixat la doi octeți, ceea ce acoperă atât numerele de generație până la 65535, cât și indecșii de membru. Câmpul 2 este cel care nu poate fi o constantă, pentru că poartă două cantități fără legătură între ele: într-o intrare de tip 1 este un deplasament în octeți limitat doar de mărimea fișierului, în timp ce într-o intrare de tip 2 este numărul obiectului-container, iar într-o intrare de tip 0 este următorul obiect liber din lanț. Un câmp 2 fix de patru octeți funcționează bine până când fișierul depășește 4 GB, moment în care fiecare deplasament de dincolo de limită se trunchiază silențios, iar întreaga tabelă devine inutilizabilă. Writer-ul, prin urmare, scanează tabela asamblată pentru cea mai mare valoare pe care o va deține vreodată vreun slot de câmp 2, inclusiv deplasamentul fluxului de referințe încrucișate însuși, și lărgește câmpul până la opt octeți

// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
  if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
    Field2Value := XRefTable[X].ObjStrNum   // type-2: container number
  else
    Field2Value := XRefTable[X].ObjPos;     // type-1 offset / type-0 next-free
  if Field2Value > MaxField2Value then
    MaxField2Value := Field2Value;
end;

Field2 := 4;
while (Field2 < 8) and
      (MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
  Inc(Field2);
Field3 := 2;   // generation numbers and member indices both fit

Odată ce lățimile sunt cunoscute, dimensiunea payload-ului este cunoscută exact, așa că writer-ul prealocă întregul buffer și îl completează după index; adăugarea intrărilor octet cu octet într-un AnsiString transformă construcția tabelei într-un proces pătratic, lucru pe care nimeni nu îl observă pe o factură de zece pagini, dar pe care toată lumea îl observă pe un document cu două sute de mii de obiecte. Alte două detalii țin cititorii stricți mulțumiți. /Index declară ce intervale de numere de obiect acoperă tabela, iar pentru o rescriere completă acesta este pur și simplu [0 N], fără goluri. Iar orice slot pe care writer-ul nu l-a emis efectiv trebuie să fie implicit liber, nu în uz: obiectul 0 conduce lanțul de obiecte libere, fiecare slot liber leagă către următorul, iar un slot care a găzduit cândva un obiect șters își păstrează numărul de generație incrementat cu unu. Nota conexă despre siguranța memoriei la analiza unor PDF-uri nesigure face același argument privind limitele, dar din partea de citire

De ce fluxul de referințe încrucișate nu trebuie criptat niciodată?

Pentru că un cititor trebuie să îl analizeze înainte de a putea ști cum să decripteze orice altceva. Fluxul de referințe încrucișate este cel care îi spune cititorului unde se află dicționarul /Encrypt; dacă octeții lui ar fi ei înșiși criptați, cititorul ar avea nevoie de cheia fișierului pentru a găsi obiectul care descrie cheia fișierului. losLab PDF Library impune acest lucru printr-un singur predicat: ShouldCryptStreamData returnează False ori de câte ori dicționarul fluxului poartă /Type /XRef, astfel încât excepția se aplică indiferent de calea prin care se ajunge la serializator

Containerul /ObjStm primește tratamentul opus, iar asimetria este intenționată. Un container este criptat integral, cu cheia derivată din propriul număr de obiect, exact ca orice alt flux. Membrii lui nu sunt criptați individual — ei sunt împachetați în forma lor de text simplu decriptat, iar singura trecere peste containerul asamblat îi acoperă, inclusiv șirurile de caractere. Dubla criptare a membrilor produce un fișier care se decriptează în text cifrat, iar pentru că stratul exterior reușește, eșecul apare ca o eroare de parsare adânc în graful de obiecte, nu ca un eșec de autentificare. Un singur obiect rămâne complet în afara acestei scheme: într-un document criptat, Catalogul este păstrat ca obiect direct de tip 1 și nu este niciodată împachetat, pentru că împachetarea lui ar obliga loader-ul să decomprime și să decripteze un flux de obiecte pentru a ajunge la rădăcina documentului, înainte ca acel context de decriptare pe care rădăcina ajută să îl stabilească să fie complet construit

Activarea împachetării din Delphi

Comutatorul public este PackObjectStreams, expus ca un câmp pe TPDFlibSaveOptions, ca setter de sine stătător SetPackObjectStreams, și ca proprietate pe obiectul document. Este activat implicit și este condiționat automat de versiune: writer-ul împachetează doar atunci când documentul este deja PDF 1.5 sau mai nou, și apelează gardianul intern de versiune minimă astfel încât un document împachetat este ridicat la 1.5 în loc să fie etichetat greșit. După salvare, GetLastSaveUsedObjectStreams raportează dacă poarta chiar s-a deschis, ceea ce reprezintă exact aserțiunea de care ai nevoie într-un test de regresie, nu o comparație a dimensiunii în octeți

var
  Doc: TPDFlib;
  Options: TPDFlibSaveOptions;
begin
  Doc := TPDFlib.Create;
  try
    if Doc.LoadFromFile('report.pdf', '') <= 0 then
      Exit;

    Doc.SetInformation(0, '1.5');        // packing is gated on PDF 1.5+

    FillChar(Options, SizeOf(Options), 0);
    Options.CompressContent    := True;
    Options.GarbageCollect     := True;  // drop orphans before packing
    Options.PackObjectStreams  := True;

    if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
      if Doc.GetLastSaveUsedObjectStreams = 1 then
        Writeln('Saved with ObjStm containers and an xref stream');
  finally
    Doc.Free;
  end;
end;

Ordinea contează între împachetare și colectarea gunoiului. Analiza de accesibilitate trebuie să ruleze prima, pentru că un membru care supraviețuiește într-un container atrage și containerul odată cu el — dacă un obiect viu este împachetat, numărul containerului său este accesibil prin definiție, iar eliminarea containerului lasă membrul blocat, fără nicio modalitate de a-l localiza. Rularea colectorului mai întâi înseamnă și că obiectele moarte nu intră niciodată într-un container, de aici provenind câștigul compus de dimensiune. Împachetarea completează celelalte pârghii de dimensiune, nu le înlocuiește; parcurgerea din optimizarea dimensiunii fișierului PDF și subsetarea fonturilor acoperă pârghiile care acționează asupra payload-urilor de flux, în timp ce fluxurile de obiecte acționează asupra structurii

Limite bune de știut înainte să activezi funcția

Salvările incrementale nu împachetează niciodată. O actualizare incrementală adaugă obiecte noi și o nouă secțiune de referințe încrucișate, lăsând revizuirile anterioare fizic intacte, astfel încât reîmpachetarea obiectelor existente în containere noi ar orfaniza intrările de tip 1 pe care revizuirea anterioară încă le referențiază; losLab PDF Library dezactivează împachetarea ori de câte ori modul de adăugare este activ, iar articolul despre actualizările incrementale și streaming-ul în mod de adăugare acoperă integral acest traseu. Documentele sub PDF 1.5 păstrează necondiționat tabela de referințe încrucișate în text simplu: un consumator 1.4 nu are nicio idee ce înseamnă /ObjStm, iar promovarea silențioasă a unui document doar pentru că writer-ul a preferat un fișier mai mic ar fi compromisul greșit de făcut în numele apelantului. O cheie opțională pe care în mod deliberat nu o emitem este /Extends, pe care ISO 32000-1 §7.5.7 o definește astfel încât un container să poată numi un predecesor, iar cititorii să poată trata un lanț de containere ca un grup logic. Este cu adevărat opțională, fiecare container pe care îl scriem este autonom și decodabil independent, iar omiterea ei elimină din writer o întreagă clasă de erori de tip ciclu și referință atârnată — deși cititorii trebuie, desigur, să respecte în continuare /Extends atunci când îl întâlnesc în fișiere provenite de la alți producători

Împachetarea în fluxuri de obiecte și generarea fluxului de referințe încrucișate fac parte din losLab PDF Library pentru Delphi și C++Builder, alături de colectorul de gunoi și optimizatorul de flux de conținut cu care se combină; pagina produsului conține referința completă a opțiunilor de salvare