Technisch artikel

Gelineariseerde PDF-output in Delphi: HotPDF Hint Tables

HotPDF schrijft gelineariseerde PDF-bestanden, de layout die Acrobat Fast Web View noemt, via de eigenschap LinearizeOutput op THotPDF. Deze instellen vóór BeginDoc laat HotPDF de voltooide objectgraaf herordenen zodat een reader die met bytebereiken kan werken pagina één kan tonen na het ophalen van alleen het beginstuk van het bestand, in plaats van eerst het hele document te downloaden. Het mechanisme is ISO 32000-1 Annex F

De reden waarom dit ertoe doet is onspectaculair. Een normale PDF plaatst zijn cross-reference-tabel aan het eind, dus een viewer moet de laatste byte bereiken voordat hij weet waar wat dan ook staat. Geef een browser een gescand rapport van 200 pagina's en de gebruiker staart naar een spinner voor de volledige overdracht, terwijl het enige wat hij wilde pagina 1 was. Linearisatie lost dat op door een kost te betalen op schrijftijdstip. Dit artikel gaat specifiek over dat schrijfpad, de partitionering, de meetlus en de harde limieten; voor de conceptuele achtergrond van wat Fast Web View je oplevert, behandelt de eerdere uitleg over PDF-linearisatie en Fast Web View dat terrein

Wat de gelineariseerde layout daadwerkelijk garandeert

Een gelineariseerd bestand is een gewone PDF met een uiterst specifieke fysieke ordening, en elke garantie die het biedt komt van die ordening, niet van enig nieuw objecttype. HotPDF geeft de delen uit in de volgorde die Annex F voorschrijft: het linearisatieparameterdictionary binnen de eerste 1024 bytes, een vroege cross-reference-tabel, de objecten op documentniveau, de primaire hint-stream, de eerste pagina en zijn private objecten, dan de resterende pagina's, dan de gedeelde objecten, dan al het overige, en tot slot de hoofd-cross-reference-tabel

De partitionering is afgeleid, niet verklaard. HotPDF loopt de referentiegraaf af vanaf elk paginaobject en registreert voor elk indirect object hoeveel pagina's het bereiken en welke pagina het als eerste bereikte. Een object dat door precies één pagina wordt gebruikt, wordt private voor die pagina. Een object dat door meer dan één wordt bereikt, wordt gedeeld. De catalogus, plus wat die ook maar referereert onder /ViewerPreferences, /OpenAction, /Threads en /AcroForm, plus het versleutelingsdictionary wanneer bescherming actief is, vormen de groep op documentniveau die aan alles vooraf moet gaan. Paginaboomnodes worden bewust achtergehouden zodat ze de sectie van de eerste pagina niet vervuilen

Het parameterdictionary draagt de getallen die een reader nodig heeft voordat hij iets anders gelezen heeft: /L voor de totale bestandslengte, /H voor de offset en lengte van de hint-stream, /O voor het objectnummer van de eerste pagina, /E voor de byte waar de sectie van de eerste pagina eindigt, /N voor het paginaaantal en /T voor de offset van het item in de hoofd-cross-reference-tabel. Elk van die getallen is een byteoffset in een bestand dat nog niet bestaat op het moment dat je ze moet schrijven

Waarom moeten de hint-table-offsets convergeren?

Omdat de getallen in het parameterdictionary het bestand beschrijven dat ze zelf bevat, en het wijzigen van een van hen het bestand verandert. Dat is de centrale moeilijkheid van een gelineariseerde writer, en het is waarom HotPDF herhaaldelijk meet in plaats van in één keer te schrijven. Verbreed /T van 6 cijfers naar 7 en het parameterdictionary groeit met een byte; de header groeit; elk object verschuift; de hoofd-cross-reference-tabel verplaatst; /T heeft nu een andere waarde nodig. De layout moet een vast punt bereiken voordat er ook maar één byte echte output wordt vastgelegd

HotPDF pakt dit aan met een begrensde iteratie. Eerst serialiseert het elk object naar een tellende stream die lengte registreert zonder bytes te bewaren, zodat elk object een bekende geserialiseerde grootte heeft. Vervolgens draait het een layoutpas die offsets toewijst aan de groep op documentniveau, de hint-stream, de groep van de eerste pagina, de latere paginagroepen, de gedeelde groep en het restant, en rapporteert waar de hoofd-cross-reference-tabel zou landen. Dat resultaat wordt teruggevoerd als invoer voor de volgende pas. De lus is begrensd op acht pogingen, en niet-convergentie werpt een exception in plaats van een bestand te produceren met plausibel ogende maar verkeerde offsets

CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
  CalculateLayout(CandidateMainOffset, FirstXRefData,
    HintOffset, EndFirstPage, NewMainOffset);
  if NewMainOffset = CandidateMainOffset then
    Break;
  CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
  raise Exception.Create('Linearization layout did not converge');

Twee details houden de lus tegen van doorslaan. Het parameterdictionary wordt geschreven in een vaste slot van 384 bytes, opgevuld met spaties, zodat zijn eigen groei de layout nooit kan destabiliseren; als de dictionary-tekst ooit die reservering zou overschrijden, werpt HotPDF een exception in plaats van stilzwijgend alles te verschuiven. En na convergentie draait HotPDF nog één bevestigende layoutpas en controleert opnieuw de lengte van de hint-stream, omdat de hint-stream zelf offsets codeert die pas bekend waren zodra de layout tot rust kwam. De opbrengst van al dit meten is dat HotPDF nooit een tweede kopie van het document buffert: zodra de offsets vastliggen, worden objecten rechtstreeks naar de bestemmingsstream geserialiseerd, met een assertie bij elke sectiegrens die controleert of de geschreven bytes overeenkomen met de beloofde offset

Het inschakelen vanuit Delphi

Het API-oppervlak is één Boolean, en de enige vereiste is dat je hem instelt vóórdat de generatie begint. LinearizeOutput staat standaard op False, en de layoutpas draait op het moment dat het document geschreven wordt, dus toewijzing na EndDoc bereikt niets

var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := 'fast-view.pdf';
    PDF.Version := pdf17;
    PDF.LinearizeOutput := True;      // must precede BeginDoc
    PDF.BeginDoc;
    PDF.Canvas.TextOut(72, 72, 'First page');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

Eén implementatiekanttekening overtreft alles aan de codekant. Linearisatie betaalt zich alleen uit wanneer het transport HTTP-bereikverzoeken ondersteunt. Serveer hetzelfde bestand vanaf een endpoint dat het in zijn geheel streamt, of vanaf een CDN-configuratie die Range negeert, en je hebt jezelf een tragere schrijfpad en een groter bestand gekocht zonder enig zichtbaar voordeel voor de gebruiker. Controleer de server voordat je de code controleert

Waarom overschrijft linearisatie UseXRefStream en UseObjectStreams?

Omdat de gelineariseerde writer voor elk object een eigen direct adresseerbare byteoffset nodig heeft, en beide functies nemen dat weg. HotPDF geeft daarom traditionele tekst-cross-reference-tabellen en uitgepakte indirecte objecten uit wanneer LinearizeOutput ingeschakeld is, zelfs als de aanroeper ook UseXRefStream of UseObjectStreams heeft ingesteld. Dit is een bewuste override, geen conflict dat je zelf moet oplossen

De redenering volgt uit de hint tables. Een hint table beschrijft waar een paginasectie begint en hoe lang die is, zodat een reader precies dat bereik kan opvragen. Een object dat verpakt zit in een /ObjStm-container heeft helemaal geen onafhankelijke offset; het bestaat alleen als een schijfje binnen een andere gecomprimeerde stream die als geheel opgehaald en uitgepakt moet worden. Als je voor bestandsgrootte op objectstreams rekende, weet dan dat linearisatie en compressie hier tegengestelde kanten op trekken, en lees de afweging in het bijbehorende stuk over objectstreams en incrementele updates in HotPDF. Dezelfde spanning vormt hybride-referentiebestanden, die precies daarom bestaan om oudere readers te laten blijven werken naast stream-gebaseerde tabellen, zoals behandeld in het artikel over hybride cross-reference-streams in door Office gegenereerde PDF's

Er is ook een versievloer. Linearisatie vereist PDF 1.2 of later. Als de geselecteerde versie ouder is, verhoogt HotPDF die automatisch, tenzij StrictVersionLock is ingesteld, in welk geval het schrijven een exception werpt in plaats van stilzwijgend een document te promoveren dat je bewust had vastgezet

De 4-GiB-muur, en waarom HotPDF weigert in plaats van af te kappen

Linearisatie-hint-tables bewaren offsets als 32-bits waarden, dus een gelineariseerd bestand kan niets adresseren op of voorbij 4 GiB, en HotPDF wijst zulke output af met een expliciete exception in plaats van een bestand met omgeslagen offsets te schrijven. De limiet is geen implementatiekeuze van HotPDF; het is de breedte van de velden die Annex F definieert

De controle wordt op drie plaatsen toegepast, en alle drie doen ertoe. HotPDF valideert elk object zodra zijn geserialiseerde lengte bekend is, valideert elke paginasectielengte tijdens het opbouwen van de hint-items, en valideert de uiteindelijke bestandslengte nadat de hoofd-cross-reference-tabel is bepaald. Vroeg falen is precies de bedoeling: een hint table met een stilzwijgend afgekapte offset produceert een bestand dat correct opent in een viewer die het als geheel downloadt en alleen faalt voor de bytebereik-client die linearisatie juist wilde bedienen, wat het slechtst mogelijke faalpatroon is omdat je testviewer het nooit reproduceert. Produceer je output van meerdere gigabytes, dan is linearisatie niet het gereedschap, en is de streamingaanpak beschreven in de notities over de Direct File API voor grote PDF-workflows de richting om naar te kijken

Linearisatie detecteren op een geladen bestand

THotPDF.IsLoadedLinearized rapporteert of het momenteel geladen document al in gelineariseerde vorm geschreven was, en het antwoordt vanuit een momentopname genomen vóór het parsen, niet vanuit de live stream. HotPDF leest de eerste 1024 bytes vanaf positie nul van de bronstream, scant die op het eerste obj-sleutelwoord en vervolgens op een /Linearized-item met waarde 1, en cachet het booleaanse resultaat

var
  PDF: THotPDF;
  PageCount: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PageCount := PDF.LoadFromFile('incoming.pdf');
    if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
      Writeln('Source is not Fast Web View ready');
  finally
    PDF.Free;
  end;
end;

Twee beperkingen in die beschrijving zijn dragend. De detectie kan niet steunen op de streampositie, omdat de parser die al verplaatst heeft tegen de tijd dat de applicatiecode de vraag stelt, en het kan niet op aanvraag herlezen omdat LoadFromFile de interne bronstream vrijgeeft zodra het laden voltooid is. Vandaar het ontwerp van vastleggen-vóór-parsen-en-cachen. De scan is ook bewust letterlijk over de waarde: alleen /Linearized 1 of een numeriek equivalente vorm met een geheel nul-breukdeel wordt geaccepteerd, omdat een bestand waarvan het parameterdictionary iets anders zegt de belofte van Annex F niet doet

Een Delphi-recordvalkuil die het waard is om over te nemen

Lokale records met dynamische arrays initialiseren hun beheerde velden en verder niets. Houd je een gewoon Count-veld naast de array, dan moet je dat zelf leegmaken. Dit beet de linearisatiepartitionering tijdens de ontwikkeling, en het is het soort bug dat een dag kost precies omdat één platform het verbergt

type
  THPDFLinearIndexList = record
    Values: THPDFIntegerArray;  // managed field: cleared for you
    Count: Integer;             // plain field: whatever was on the stack
  end;

// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);

Het dynamische-arrayveld is reference-counted, dus de compiler nult het. De Count ernaast is een gewone integer zonder zo'n garantie, en een niet-geïnitialiseerde Count stuurt de allereerste toevoeging naar een willekeurige index. Onder Win32 bevatte de stackslot toevallig nul, landde de toevoeging op index 0, en slaagde elke test. Onder Win64 schreef dezelfde code voorbij het einde van de array. De les generaliseert ver voorbij linearisatie: wanneer een record beheerde en onbeheerde velden mengt, wijs dan Default(TRecord) toe en stop met redeneren over welke velden de compiler afdekt, en behandel een groene Win32-run nooit als bewijs dat de initialisatie correct is

De hier beschreven leden LinearizeOutput en IsLoadedLinearized worden geleverd met de standaard HotPDF Component voor Delphi en C++Builder; de productpagina draagt de volledige eigenschapreferentie, inclusief de interactieregels met cross-reference-streams, objectstreams en versievergrendeling