Open een PDF die Microsoft Word of Excel heeft geproduceerd, blader erdoorheen, en niets oogt ongewoon. Laad hem in een Delphi-programma, lees het pagina-aantal terug, en het getal klopt. Sla hem daarna opnieuw op met versleuteling aan en de opslaatstap gooit een exceptie, of de uitvoer opent met een waarschuwing over een beschadigde cross-reference. Het bestand was nooit corrupt. Het is een hybride-referentiebestand, en juist de structuur die een vijftien jaar oude viewer hem laat openen is de structuur die een loader verslaat die te vroeg stopt met lezen
Dit is een van de meest voorkomende manieren waarop een PDF-pijplijn die elke interne test heeft doorstaan, een bestand tegenkomt dat het niet foutloos heen en weer kan sturen (round-trip). De invoer is allemaal in-house gegenereerd, dus deze was nooit hybride. Het eerste hybride bestand arriveert op de dag dat een klant een factuur doorstuurt die vanuit een spreadsheet is geëxporteerd
Wat Word en Excel daadwerkelijk schrijven
ISO 32000-1 beschrijwt de lay-out van hybride-referenties in §7.5.8.4. Een applicatie die PDF 1.5-functies wil, zoals objectstromen, maar toch een PDF 1.4-lezer het bestand wil laten openen, schrijft de kruisverwijzingsinformatie tweemaal. Er is een klassieke kruisverwijzingstabel (cross-reference table), de ASCII-rijen met vaste breedte die elke PDF tot versie 1.4 beëindigden, en er is een kruisverwijzingsstroom (cross-reference stream) die de rest indexeert. De trailer van het klassieke gedeelte bevat een /XRefStm-vermelding waarvan de waarde de byte-offset van die stroom is
De verdeling van de taken is opzettelijk. Objecten die een oude reader moet kunnen bereiken, waaronder de catalogus en de paginaboom, zijn adresseerbaar vanuit de klassieke tabel. Objecten die zijn samengevouwen in gecomprimeerde objectstreams worden in de klassieke tabel als vrij gemarkeerd, met een type f-entry, zodat een 1.4-reader er direct voorbijgaat en nooit struikelt over een structuur die hij niet kan parsen. Hun werkelijke locaties leven alleen in de cross-reference-stream. De signatuur van zo’n bestand is zijn staart: een korte klassieke sectie die alleen de objecten dekt die een legacy-reader nodig heeft, waarvan de trailer wijst naar de /XRefStm waar de werkelijke hersteldata zit
/XRefStm-offset in zijn trailer leidt naar de stream die de rest van de objectindex bevatWaarom een correct aantal pagina's niets bewijst
Omdat de catalogus en de paginaboom met opzet bereikbaar zijn vanuit de klassieke tabel, vindt een lader die alleen die tabel leest /Root, doorloopt de paginaboom, en rapporteert het juiste aantal pagina's. Alles wat een oude lezer nodig heeft is aanwezig, dus het bestand lijkt gezond. De objecten die ontbraken zijn degene die in objectstromen zijn verpakt: AcroForm-veldwoordenboeken (field dictionaries), structuurelementen voor getagde PDF's, de lange reeks kleine woordenboeken die nooit zichtbaar hoefden te zijn voor een verouderde viewer
Je merkt het gat pas op wanneer iets die objecten raakt, en een volledige herschrijf-actie (resave) raakt ze allemaal. Het doorlopen van het document om het opnieuw te versleutelen of te herschrijven is precies de bewerking die achtereenvolgens om elk objectnummer vraagt, en dat is de reden waarom het symptoom naar de oppervlakte komt op het moment van opslaan in plaats van op het moment van laden, ver verwijderd van de oorzaak
De valstrik is een detector die xref ziet en stopt
De goedkope manier om te bepalen hoe een bestand is geïndexeerd, is door startxref te volgen en de eerste bytes waarnaar dit wijst te inspecteren. Het trefwoord xref betekent een klassieke tabel; een stroomobject (stream object) betekent een kruisverwijzingsstroom. Die test is correct voor elk bestand dat zich vastlegt op één schema. Het is fout voor een hybride bestand, waarvan de startxref zich richt op een klassiek gedeelte met als enige doel oude lezers tevreden te stellen, terwijl de /XRefStm in de trailer van dat gedeelte de plek is waar het grootste deel van het document daadwerkelijk is geïndexeerd. Een detector die "klassiek" retourneert bij de eerste xref die deze tegenkomt, leest nooit /XRefStm, en elk object dat alleen in de stroom leeft, wordt onzichtbaar
var
Pdf: THotPDF;
PageCount: Integer;
begin
Pdf := THotPDF.Create(nil);
try
PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf'); // pagina’s zijn bereikbaar vanuit de klassieke tabel
// inspecteer of bewerk hier het geladen document
Pdf.SaveLoadedDocument('Invoice_secured.pdf'); // heeft elk object nodig, ook de stream-only
finally
Pdf.Free;
end;
end;
Draai die reeks op een loader gebouwd rond de early-exit-detector en het laden oogt prima; het opnieuw opslaan is waar de afwezige objecten zich aankondigen. De oplossing is niet aan het begin meer bytes lezen; het is de hybride trailer herkennen en /XRefStm volgen voordat wordt besloten dat het bestand klaar is
Samenvoegvolgorde is niet onderhandelbaar
Zodra beide indexen zijn gelezen, kunnen ze slechts in één richting worden gecombineerd. De kruisverwijzingsstroom moet eerst worden samengevoegd, waarna de klassieke ingangen eromheen worden ingevuld. De reden hiervoor is de kleine misleiding in het hart van het formaat. Een hybride bestand markeert de gecomprimeerde objecten als vrij in de klassieke tabel, zodat oude lezers ze negeren. Een lader die een 'first-seen-wins'-beleid hanteert en de klassieke tabel als eerste leest, zal die objectnummers als vrij registreren en vervolgens de stroomingangen verwijderen die ze daadwerkelijk lokaliseren, omdat de slots al bezet zijn. Draai de volgorde om en de type 2-ingangen uit de stroom, elk een object-stroomnummer plus een index, winnen de slots die ze horen te bezitten, en de klassieke ingangen nestelen zich daar omheen
Dezelfde discipline beschermt tegen een oudere revisie die een verwijderd object nieuw leven inblaast. Incrementele updates ketenen achterwaarts door middel van /Prev, en een type 0 vrije ingang is een waarschuwing dat een recenter gedeelte een objectnummer buiten gebruik heeft gesteld. Een latere, oudere sectie in de keten mag deze waarschuwing niet overschrijven met een verouderde locatie. Behandel de eerste waarneming (first-seen) als gezaghebbend voor vrije markeringen en het verwijderde object blijft verwijderd; behandel het onzorgvuldig en de eigen geschiedenis van een bestand reanimeert de inhoud die door de nieuwste revisie is verwijderd
Hoe de twee beelden uit elkaar drijven
Al dat veronderstelt dat de twee indexen het eens zijn. Rechtstreeks uit Word komen ze dat: elk beeld beschrijft hetzelfde document binnen het bereik dat hij declareert. De ellende begint wanneer het bestand door een tool gaat die er maar één van begrijpt. Stamping-utilities zijn de gebruikelijke verdachten. Eén plakt een incrementele update in klassieke stijl met een paar nieuwe objecten, een nieuwe xref-sectie, een /Prev-link terug naar de vorige sectie, en een verse trailer. Laat die trailer /XRefStm vallen, dan wordt het streambeeld wees. Kopieert hij de oude waarde door, dan beschrijft het streambeeld het document nog zoals het was voordat de stempel erop kwam. Hoe dan ook zijn de indexen het nu oneens
De signatuur is makkelijk te herkennen eens u hem kent. Objecten die door het ene beeld zichtbaar zijn ontbreken in het andere of zijn verouderd. Een reader die via de stream oplost, vindt de versie van vóór de bewerking van een bijgewerkt object, of helemaal geen entry voor een toegevoegde. Een reader die alleen de tabel vertrouwt ziet de bewerking maar verliest elk gecomprimeerd object dat alleen de stream kon lokaliseren. Gebruikers omschrijven het als formuliervelden die in de ene viewer opduiken en in de andere verdwijnen, annotaties die de stamppasse schijnbaar heeft verwijderd, of lookups die op een volstrekt verkeerd object belanden
Adobe Acrobat is wat deze bestanden zo duur maakt om na te jagen. Als de gedeclareerde index tegen de bytes ingaat, bouwt Acrobat de cross-reference-data doorgaans stilletjes opnieuw op door naar objectheaders te scannen, dus wie het kapotte bestand produceerde opent het, ziet niets geks, en stuurt het door. De faling komt later boven water en ergens anders — een preflight-validator, een signing-service, een archiverings-ingest-taak — elke consument die de gedeclareerde structuur vertrouwt en ontbrekende objecten of een cross-reference-mismatch meldt. "Hij opent prima in Acrobat" is hoe vrijwel elk ticket over hybride desynchronisatie begint
Wat dit betekent in HotPDF
De engine lost hybride-referentiebestanden voor u op, en wel op elk pad dat de cross-reference-data moet parsen. Laad een document met LoadFromFile of LoadFromStream, breng uw wijzigingen aan, en roep SaveLoadedDocument aan; of draai een eenmalige operatie zoals EncryptFile die invoer leest en uitvoer schrijft. Hoe dan ook leest de herstel /XRefStm, voegt de streamsectie vóór de klassieke entries samen, en lost de objecten die in streams wonen op voordat de schrijfactie ze opsomt. Het AES-256-versleutelpad is waar dit het zichtbaarst telt, want het versleutelen van een document herschrijft elk object en eist dus dat elk object al is gelokaliseerd
// Eenmalig: lees de hybride invoer, schrijf een met AES-256 versleutelde kopie
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
'owner-secret', '', aes256, [prPrint, prFillAnnotations]);
Het detail dat de moeite waard is om te onthouden, bevindt zich stroomopwaarts van de API. Bestanden die afkomstig zijn van Word, Excel, PowerPoint en een lange lijst van "Opslaan als PDF"-pijplijnen zijn routinematig hybride, dus een lader die u alleen test met uitvoer van uw eigen generator zal er misschien nooit een tegenkomen in een testomgeving. Vul uw testsets aan met documenten die uit echte Office-applicaties zijn geëxporteerd, en niet alleen met bestanden die door uw eigen code zijn geproduceerd
Een bestand controleren dat u verdenkt
Twee inspecties beslechten de kwestie snel. Open het bestand in een hex-weergave en lees de bytes na de laatste startxref; een hybride bestand toont een kort klassiek gedeelte waarvan het trailer-woordenboek /XRefStm bevat. Of vergelijk de objecttelling die een volledige parse rapporteert met het hoogste objectnummer dat /Size in de trailer declareert. Een groot hiaat betekent dat objecten zich verbergen in stromen die de lader niet heeft geopend, en dat is hetzelfde tekort dat later verandert in een storing bij het opslaan
De staart van een typische Excel-export maakt de eerste controle concreet. Alles na het laatste trefwoord xref is platte ASCII, dus de handtekening kan direct uit een hex-weergave worden afgelezen (offsets ter illustratie, annotaties toegevoegd)
xref
0 16 % objecten 0-15: wat een legacy-reader nodig heeft
0000000000 65535 f
0000000017 00000 n
... % nog 14 rijen; de rest woont in objectstreams
trailer
<< /Size 216 % één boven het hoogste objectnummer in gebruik
/Root 1 0 R
/Info 15 0 R
/ID [<5C9A...> <5C9A...>]
/XRefStm 87325 % byte-offset van de cross-reference-stream
>>
startxref
88710 % wijst naar de klassieke sectie hierboven
%%EOF
De /XRefStm-sleutel in de trailer is de aanwijzing. De klassieke sectie erboven noemt 16 van de 216 objectnummers die het bestand gebruikt; alles wat in objectstreams is gepakt ontbreekt daarin of staat als vrij gemarkeerd. Een detector die bij het xref-sleutelwoord stopt heeft op dit punt alleen die partiële index gezien. Wilt u de controle liever scripten dan met het oog doen, de marker zit binnen de laatste paar kilobytes van het bestand, dus een begrensd achterwaartse leesbeurt is genoeg. Is het bestand later incrementeel bijgewerkt, dan bevat de staart meer dan één trailer, en de laatste is degene die telt
// Geeft de /XRefStm-offset terug uit de nieuwste trailer in de staart van het bestand,
// of -1 als de marker ontbreekt (het bestand is niet hybride, of helemaal geen PDF)
function FindXRefStm(const FileName: string): Int64;
var
FS: TFileStream;
Tail: AnsiString;
Len, P, Q: Integer;
begin
Result := -1;
FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
Len := 2048; // de trailer woont in de staart
if FS.Size < Len then
Len := Integer(FS.Size);
FS.Position := FS.Size - Len; // begrensd achterwaarts lezen: max 2 KB
SetLength(Tail, Len);
FS.ReadBuffer(Tail[1], Len);
finally
FS.Free;
end;
P := 0; // houd de laatste marker vast: incrementeel
Q := Pos(AnsiString('/XRefStm'), Tail); // bijwerken plakt nieuwere trailers achteraan
while Q > 0 do
begin
P := Q;
Q := Pos(AnsiString('/XRefStm'), Tail, P + 1);
end;
if P = 0 then
Exit; // geen hybride marker in de staart
Inc(P, Length('/XRefStm'));
while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
Inc(P); // sla witruimte na de sleutel over
Result := 0;
while (P <= Len) and (Tail[P] in ['0'..'9']) do
begin
Result := Result * 10 + Ord(Tail[P]) - Ord('0');
Inc(P);
end;
end;
// Gebruik: een niet-negatief resultaat benoemt de byte waar de stream begint
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
Writeln('hybrid-reference file: resave will need the /XRefStm section');
Behandel de probe als triage, niet als parser: hij vertelt u welke bestanden in een batch aandacht verdienen voordat een opslaan-taak draait, en niets meer
Richt hem op een map met echte invoer en de bestanden sorteren in drie groepen. Een klassiek-bestand heeft een trailer-sleutelwoord in zijn staart maar geen /XRefStm, dus de probe geeft -1 terug. Een bestand dat volledig op cross-reference-streams inzet geeft ook -1 terug, en terecht. Het heeft helemaal geen trailer-sleutelwoord, want zijn trailer-sleutels wonen in de streamdictionary. Alleen de dubbel geïndexeerde indeling levert een offset op. Moet uw triagerapport de eerste twee groepen uit elkaar houden, zoek dan in dezelfde staart naar trailer voordat u haar weggooit; nog één Pos-aanroep doet dat
Voordat de probe onbeheerd draait, verhardt u hem op twee plekken. Een offset is slechts een claim. Seek ernaartoe en bevestig dat daar werkelijk een object zit waarvan de dictionary /Type /XRef zegt, want een beschadigd of afgebroken bestand kan de sleutel nog dragen terwijl de stream die hij noemt verdwenen is. Dat bestand hoort in een andere bak dan een gezonde hybride. De controle is een paar regels bovenop dezelfde bestandsafhandeling
// Offset is de waarde die FindXRefStm teruggaf; FS is een TFileStream geopend
// op dezelfde manier, Head een AnsiString (Min komt uit System.Math)
if Offset >= FS.Size then
Exit(False); // de sleutel overleefde, de stream niet
FS.Position := Offset;
SetLength(Head, Integer(Min(Int64(512), FS.Size - Offset)));
FS.ReadBuffer(Head[1], Length(Head));
Result := (Pos(AnsiString('/Type /XRef'), Head) > 0) or
(Pos(AnsiString('/Type/XRef'), Head) > 0); // PDF-syntaxis staat beide spellingen toe
Maak daarna het venster van 2 KB een parameter. Twee kilobytes dekt gewone Office-uitvoer, maar een uitzonderlijk grote trailer-dictionary kan de marker buiten bereik duwen, en het venster verbreden verslaat het bestand per ongeluk klassiek verklaren
Hybride bestanden door een pipeline routeren
Detectie verdient zijn geld wanneer hij verandert waar een bestand daarna heengaat. Waar dat is hangt af van wat de pipeline ermee van plan is
Bestanden die alleen worden gelezen, gerenderd of gevalideerd horen bij een loader die beide beelden oplost. De PDFium Component parst de /XRefStm-keten tijdens het laden, dus de objecttabel die uw code ziet is al de samengevoegde, en de controles in ons artikel over het valideren van object- en cross-reference-streams gelden ongewijzigd. Is een gedesynchroniseerde hybride zo zwaar beschadigd dat het laden weigert, dan meldt de engine dat via zijn load-foutcode, en FPDF_ERR_FORMAT is degene die structurele schade produceert. Een schone load is zwakker bewijs. PDFium is van ontwerp soepel en bouwt de meeste inconsistente bestanden zonder commentaar opnieuw op, dus succes bewijst dat het bestand herstelbaar was en zegt niets over of zijn twee beelden het eens zijn. Voor die vraag gaat u terug naar het vergelijken van wat een volledige objectwandeling vindt met de /Size van de trailer
Bestanden die de pipeline bewerkt zijn een ander verhaal. Onze voorkeur hier is bot: voorkom dat ze überhaupt hybride zijn. Laad het document met HotPDF en sla het volledig op, en de uitvoer draagt één enkele, zelfconsistente cross-reference in één vorm. Geen /XRefStm, geen tweede beeld dat kan gaan drijven, elk object eigendom van precies één indexentry. Die normalisatie is wat u wilt vóór archiverings-ingest, vóór een strenge downstream RIP of signing-service, en na elke bewerking op een hybride invoer. Hij leunt volledig op de stream-eerst-samenvoeging die eerder werd beschreven, want een volledige opslag kan alleen de objecten wegschrijven die het load heeft weten te lokaliseren
Ondertekende documenten zijn de uitzondering. Een volledige herschrijving verplaatst elke byte, wat elke handtekening die over de originele byteranges is berekend ongeldig maakt. Een wijziging aan een ondertekende hybride moet binnenkomen als een keurige incrementele update die beide beelden in de pas houdt, en een ondertekend bestand dat alleen gelezen hoeft te worden passeert de pipeline onaangetast
De kant van de schrijver in dit verhaal, over hoe objectstromen en gecomprimeerde kruisverwijzingen überhaupt worden geproduceerd, wordt behandeld in ons artikel over objectstromen en incrementele updates. Wanneer het betreffende hybride bestand ook zeer groot is, laten de laadtechnieken in de Direct File API walkthrough voor grote PDF-workflows u toe dit te inspecteren zonder het in zijn geheel in het geheugen te lezen. Beide gaan natuurlijk samen met het hier beschreven herstel, dat wordt geleverd als onderdeel van de HotPDF-Delphi-component voor Delphi en C++Builder, naast de API's voor laden, bewerken, coderen en ondertekenen die elders op deze blog aan bod komen