Technisch artikel

Hybride-referentie PDF's laden vanuit Word en Excel in Delphi

Open een PDF die Microsoft Word of Excel heeft geproduceerd, blader erdoorheen, en niets ziet er ongewoon uit. Laad deze in een Delphi-programma, lees het aantal pagina's terug, en het getal klopt. Sla het bestand vervolgens opnieuw op met codering ingeschakeld en de taak mislukt met een EListError, of de uitvoer opent met een waarschuwing over een beschadigde kruisverwijzing. Het bestand was nooit corrupt. Het is een hybride-referentiebestand, en precies de structuur die een vijftien jaar oude viewer in staat stelt het te openen, is de structuur die een lader 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 lezer moet kunnen bereiken, waaronder de catalogus en de paginaboom (page tree), zijn adresseerbaar vanuit de klassieke tabel. Objecten die zijn samengevouwen in gecomprimeerde objectstromen worden gemarkeerd als vrij in de klassieke tabel, met een type f vermelding, zodat een 1.4-lezer er direct aan voorbijgaat en nooit struikelt over een structuur die deze niet kan parseren. Hun werkelijke locaties leven alleen in de kruisverwijzingsstroom. De handtekening van zo'n bestand is zijn staart: een kort klassiek gedeelte, vaak niets meer dan xref gevolgd door een 0 0 subsectie-header, waarvan de trailer wijst naar de /XRefStm waar de eigenlijke herstelgegevens zich bevinden

Waarom 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');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

Met de early-exit detector op zijn plaats lijkt het laden in orde en is het opslaan de plek waar de afwezige objecten zich aankondigen. De oplossing is niet om meer bytes aan het begin te lezen; het is om de hybride trailer te herkennen en /XRefStm te volgen voordat wordt besloten dat het bestand gereed 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

Wat dit betekent in HotPDF

De engine lost hybride-referentiebestanden voor u op, en doet dit op elk pad dat de kruisverwijzingsgegevens moet parseren. Laad een document met LoadFromFile of LoadFromStream, breng uw wijzigingen aan en roep SaveLoadedDocument aan; of voer een eenmalige actie uit zoals EncryptFile die een invoer leest en een uitvoer schrijft. In beide gevallen leest het herstel /XRefStm, voegt het het stroomgedeelte toe vóór de klassieke ingangen en lost het de objecten op die in stromen leven voordat de schrijfoperatie ze opsomt. Het AES-256 coderingspad is de plek waar het probleem zich voor het eerst manifesteerde, omdat het coderen van een document elk object herschrijft en dus vereist dat elk object al is gelokaliseerd

// One-shot: read the hybrid input, write an AES-256 encrypted copy
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 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

De 0 0 subsectie is de aanwijzing: een klassieke tabel met nul ingangen bestaat alleen om de trailer te dragen, en de trailer bestaat voornamelijk om /XRefStm 87325 te zeggen. Een detector die stopt bij het trefwoord xref heeft op dat moment een index van niets gezien. Als u de controle liever script dan visueel controleert: de markering bevindt zich altijd binnen de laatste paar kilobytes van het bestand, dus een begrensde achterwaartse leesactie is voldoende

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  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;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

Behandel de sonde als triage, niet als parser: deze vertelt u welke bestanden in een batch aandacht verdienen voordat een opslagtaak wordt uitgevoerd, en niets meer. Wat een lader vervolgens moet doen met de gevonden offset, het volgen van de keten van secties, het samenvoegen van stroomingangen vóór de klassieke ingangen, met inachtneming van vrije-ingang-waarschuwingen, wordt stap voor stap uitgelegd in ons bijbehorende artikel over het omgaan met hybride-referentie PDF's uit Office-applicaties

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-component voor Delphi en C++Builder, naast de API's voor laden, bewerken, coderen en ondertekenen die elders op deze blog aan bod komen