Technisch artikel

Gelineariseerde PDF-output in Delphi: HotPDF Hint Tables

Het eerste object in een gelineariseerde PDF moet de exacte lengte van het bestand waarin hij zit aangeven, tot op de byte, en hij moet binnen de eerste 1024 bytes verschijnen. ISO 32000-1 Annex F noemt die entry /L, en een reader die hem met de echte bestandsgrootte ziet botsen krijgt de opdracht het document als een gewone PDF te behandelen en elke linearisatiehint erin te negeren. De writer moet dus de lengte printen van een bestand dat hij nog niet klaar heeft met schrijven, naast de offset van een hint stream, het einde van de eerste pagina en de positie van een cross-reference-tabel nabij het einde van het bestand. Alle vier de getallen verschuiven zodra iets ervoor van grootte verandert

De opbrengst is Fast Web View. Een gewone PDF bewaart zijn cross-reference-tabel achteraan, dus een browser die een gescand rapport van 200 pagina's krijgt kan pagina 1 pas vinden als de laatste byte binnen is; een gelineariseerd bestand zet pagina 1 en een klein cross-reference-deel vooraan, dus een client die HTTP range requests gebruikt tekent de eerste pagina vroeg. Wat volgt is de schrijfkant. Het lezersbeeld staat in de uitleg over PDF-linearisatie en Fast Web View

Hoe HotPDF een gelineariseerd bestand indeelt

HotPDF schrijft gelineariseerde output wanneer THotPDF.LinearizeOutput op True staat, en het bestand volgt de fysieke volgorde die Annex F voorschrijft, deel voor deel. Na de header komt het linearisatie-parameterdictionary, dan het cross-reference-deel van de eerste pagina met zijn trailer, de catalog met de andere documentniveau-objecten, de primaire hint stream, de eerste pagina met elk object dat hij nodig heeft, de resterende pagina's op volgorde, de objecten die latere pagina's delen, alles wat overblijft, en tenslotte de hoofdcross-reference-tabel

Bytevolgorde-diagram van een gelineariseerde PDF geschreven door HotPDF in Delphi, van het linearisatie-parameterdictionary en de primaire hint stream tot aan de hoofdcross-reference-tabel
HotPDF emitteert de Annex F-delen in een vaste bytevolgorde, met het parameterdictionary en de primaire hint stream vroeg genoeg geplaatst dat byte-range-lezers bijna meteen pagina één kunnen renderen

Niets in het documentmodel zegt bij welk deel een object hoort. De writer working dat uit vanuit de referentiegraaf op het moment dat EndDoc opslaat, wandelend vanaf elk paginaobject en tellend hoeveel pagina's elk indirect object bereiken. Een object dat vanaf één pagina wordt bereikt gaat naar het deel van die pagina. Een object dat vanaf meerdere pagina's wordt bereikt is gedeeld, met één nuance die een snelle lezing van de spec mist: wanneer pagina 1 tot de gebruikers behoort, gaat het object toch naar het eerste-pagina-deel, want pagina 1 moet uit de voorste bytes alleen kunnen renderen. Alleen objecten die puur onder latere pagina's worden gedeeld wachten in het gedeelde deel. De catalog, wat zijn /ViewerPreferences-, /OpenAction-, /Threads- en /AcroForm-entries ook aanwijzen, en het versleutelingsdictionary van een beveiligd document vormen samen de documentniveau-groep. Paginaboomknooppunten worden in het achterste deel geduwd zodat ze nooit de eerste pagina opblazen

Open de output in een hexeditor en de nummering oogt achterstevoren. Het voorste blok, van het linearisatie-dictionary tot de hint stream, draagt de hoogste objectnummers van het bestand, terwijl alles erna van 1 af wordt genummerd. Het kleine cross-reference-deel vooraan dekt precies dat hoge blok, en de hoofdtabel dekt 1 tot en met N

Een record-initialisatievalkuil in partitionlijsten

Lijsten zoals deze worden natuurlijk geschreven als een record met een dynamische array plus een aparte count, en juist die vorm verbergt een Delphi-valkuil. De compiler initialiseert de beheerde velden van een lokaal record. De kale Integer ernaast krijgt niets. Onder Win32 houdt de stackslot toevallig vaak nul, dus de eerste append landt op index 0 en elke test slaagt; onder Win64 kan het slot restjes bevatten en schrijft de eerste append naar een willekeurige index

type
  TPartList = record
    Values: TArray<Integer>;  // beheerd: door de compiler geleegd
    Count: Integer;           // onbeheerd: wat de stack toevallig bevatte
  end;

procedure Append(var List: TPartList; Value: Integer);
begin
  if List.Count = Length(List.Values) then
    SetLength(List.Values, List.Count * 2 + 16);
  List.Values[List.Count] := Value;
  Inc(List.Count);
end;

procedure BuildParts;
var
  FirstPage, Shared: TPartList;
begin
  FirstPage := Default(TPartList);   // vereist, niet cosmetisch
  Shared := Default(TPartList);
  Append(FirstPage, 12);
  Append(Shared, 40);
end;

De writer van HotPDF wijst elke partitionlijst Default toe voordat hij haar aanraakt. Een groene Win32-run zegt niets over of zo'n record was geïnitialiseerd

Waarom moet de layout convergeren voordat de eerste byte wordt weggeschreven?

Omdat de voorkant van het bestand offsets bevat die naar de achterkant wijzen, en het aantal cijfers in die offsets alles erachter verschuift. De trailer van de eerste pagina draagt een /Prev-entry met de byte-offset van de hoofdcross-reference-tabel. Schrijf die offset als 99998, kom er later achter dat de tabel echt op 100003 landt, en de trailer groeit met één cijfer. Het cross-reference-deel van de eerste pagina groeit mee, elk object erachter schuift één byte op, en de hoofdtabel zit nu op 100004. Eén enkele pass krijgt dit alleen bij toeval goed

HotPDF doorbreekt de cirkel in twee zetten. Het parameterdictionary gaat in een vaste slot van 384 bytes, opgevuld met spaties, zodat /L, /H, /E en /T de cijfers kunnen pakken die ze nodig hebben zonder ook maar één object te verschuiven; zou de dictionarytekst ooit meer dan 384 bytes nodig hebben, dan gooit de schrijfwijze een exception in plaats van het bestand te verschuiven. Daarmee is /Prev de enige vrije variabele, en hij loopt door een fixed-point-lus. Elk object wordt eerst geserialiseerd naar een counting stream die lengtes vastlegt en geen bytes houdt. Een layoutpass wijst daarna offsets sectie voor sectie toe en meldt waar de hoofdtabel zou landen, en dat antwoord wordt de input van de volgende pass. De lus stopt wanneer de voorgestelde offset ophoudt te bewegen en geeft na acht pogingen het op met een exception

Stroomdiagram van de HotPDF-linearisatie-layout-convergentielus in Delphi, waarin voorgestelde cross-reference-offsets herhaaldelijk worden gemeten totdat ze een fixed point bereiken of een exception de schrijfwijze afbreekt
Tot acht meetpassen convergeren de hoofdcross-reference-offset voordat er ook maar één byte wordt vastgelegd; niet-convergentie gooit een exception in plaats van gegokte offsets weg te schrijven

Alleen een verandering in cijferaantal verschuift iets, dus een derde pass is alleen nodig nabij een macht-van-tien-grens, zoals in het geval 99998. Na de convergentie draait de writer één bevestigende pass, herbouwt de hint stream met echte offsets en controleert dat zijn lengte niet veranderde; de offsets zitten in velden met vaste breedte, dus de placeholder-versie en de definitieve moeten matchen. Daarna gaat de output rechtstreeks naar de doelstroom zonder gebufferde tweede kopie, en bij elke sectiegrens worden de weggeschreven bytes vergeleken met de beloofde offset. Een mismatch gooit vanuit EndDoc

Hint tables, en waar Annex F op twee manieren leest

De primaire hint stream draagt twee tabellen. De page offset hint table legt per pagina vast hoeveel objecten zijn sectie bevat, hoe lang die sectie in bytes is, en van welke gedeelde objecten hij afhangt. De shared object hint table somt de lengte van elke gedeelde-objectgroep op. Daarmee kan een reader pagina 37 direct opvragen, en de /S-entry van de stream geeft de positie binnen de stream waar de gedeelde tabel begint

Twee details in Annex F F.4 zijn makkelijk fout te doen. Het eerste is dat offsets in de hint tables logische offsets zijn, berekend alsof de primaire hint stream niet bestond. Het eerste paginaobject en het eerste gedeelde object zitten fysiek beide na de hint stream, dus de writer trekt de lengte van het hint-object af voordat hij hun offsets vastlegt. Een writer die in plaats daarvan fysieke offsets vastlegt, ziet elk daaruit voortvloeiend range request precies de grootte van het hint-object ernaast zitten

Het tweede is een echte ambiguïteit. De tekst van F.4 beschrijft elke tabel als één doorlopende bitstroom, most significant bit eerst, en somt de gedeelde groepen van de eerste pagina en de latere gedeelde groepen op als twee aparte reeksen. Hint streams die in omloop zijn vullen elke kolom aan tot een bytegrens en behandelen de gedeelde groepen als één gecombineerde lijst, en dat is de vorm die hint-verwerkende readers naar wezenlijk weten accepteren. HotPDF schrijft de byte-uitgelijnde, gecombineerde layout. Dat is de juiste kant om te kiezen: een tabel die aan de letter van F.4 voldoet maar voor de clients die hem lezen verkeerd uitgelijnd is, faalt precies waar linearisatie zijn geld moest opleveren, en een volledige download van het bestand legt het probleem nooit bloot

Gelineariseerde output vanuit Delphi aanzetten

Aan de API-kant is de schakelaar één Boolean. LinearizeOutput default naar False, en de writer raadpleegt hem wanneer EndDoc het document serialiseert, dus zet hem bij de andere output-opties vóór BeginDoc en laat hem daarna met rust. Elke hierboven beschreven mislukking (niet-convergentie, een te groot parameterdictionary, een grensmismatch, de hieronder behandelde 4-GiB-plafond) komt naar boven als een exception vanuit EndDoc, wat een fallback eenvoudig maakt

procedure WriteReport(const FileName: string; Linearize: Boolean);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := FileName;
    PDF.Version := pdf17;
    PDF.LinearizeOutput := Linearize;   // vóór BeginDoc zetten
    PDF.BeginDoc;                       // maakt pagina 1 aan
    PDF.CurrentPage.TextOut(72, 72, 0, 'Quarterly summary');
    PDF.AddPage;
    PDF.CurrentPage.TextOut(72, 72, 0, 'Details');
    PDF.EndDoc;                         // layout en wegschrijven gebeuren hier
  finally
    PDF.Free;
  end;
end;

procedure PublishReport(const FileName: string);
begin
  try
    WriteReport(FileName, True);
  except
    on E: Exception do
    begin
      LogWarning('Linearization failed: ' + E.Message);  // uw eigen logger
      WriteReport(FileName, False);                       // gewone PDF in plaats daarvan
    end;
  end;
end;

De fallback levert een gewone PDF op die elke viewer opent; hij verliest alleen de snelle eerste pagina. Voor een rapportpijplijn is die afweging gemakkelijk, want een trage eerste paint is een ergernis en een ontbrekend rapport is een incident

De inzetkant weegt zwaarder dan de code. Linearisatie betaalt zich alleen terug wanneer de server HTTP Range-requests honoreert; een endpoint dat het bestand in één keer streamt koopt u een tragere schrijfwijze en een groter bestand voor niets. Laat LinearizeOutput uit voor bestanden die worden gemaild, bijgevoegd of gedownload voordat iemand ze opent. Zet hem aan voor documenten die een browser direct vanaf een URL opent, nadat u heeft bevestigd dat het antwoord Accept-Ranges: bytes draagt

Waarom schakelt LinearizeOutput object streams uit?

Omdat elke hint-table-entry een bytebereik beschrijft, en een object dat in een /ObjStm-container is gepakt geen eigen bytebereik heeft; hij bestaat alleen als plak van een andere gecomprimeerde stream die geheel moet worden opgehaald en geïnflateerd. Met LinearizeOutput aan schrijft HotPDF traditionele tekst-cross-reference-tabellen en uitgepakte indirecte objecten, ook wanneer UseXRefStream of UseObjectStreams ook is gezet. Het gelineariseerde pad gaat bij de save voor, dus uw code heeft geen conflict op te lossen

De kost is bestandsgrootte, want de kleine dictionaries die een object stream samen zou comprimeren blijven nu los. Als compacte output zwaarder weegt dan first-page-latentie, staat de afweging uitgelegd in object streams en incrementele updates in HotPDF. Office-exporters lopen van de andere kant tegen dezelfde spanning aan, en daarom emitteert een deel van hen de layout die wordt beschreven in hybride cross-reference-streams in door Office gegenereerde PDF's

Linearisatie heeft ook PDF 1.2 of later nodig. Bij een lagere Version promoveert HotPDF het document bij het opslaan naar 1.2. Met StrictVersionLock aangezet gooit hij in plaats daarvan een exception, want een versie die u met opzet heeft vastgezet hoort niet stilletjes achter uw rug te veranderen

Het 4-GiB-plafond op gelineariseerde output

Hint tables bewaren offsets en lengtes in 32-bits velden, dus een gelineariseerd bestand kan niets voorbij 4 GiB adresseren, en HotPDF weigert er één te produceren in plaats van offsets weg te schrijven die omslaan. Het plafond komt uit de veldbreedtes die Annex F definieert

Diagram van de drie HotPDF-controleposten die objectgroottes, paginasectielengtes en de uiteindelijke bestandslengte afzetten tegen de 4-GiB-limiet van de 32-bits linearisatie-hint-table-velden in Delphi
Object-, paginasectie- en hele-bestands-metingen worden elk tegen het 32-bits hint-veld-plafond getest voordat output wordt geproduceerd

De controle draait op drie punten, alle drie voordat de eerste byte het doel bereikt: op elk object zodra zijn geserialiseerde lengte bekend is, op elke paginasectielengte terwijl de hint-entries worden gebouwd, en op de uiteindelijke bestandslengte zodra de hoofdcross-reference-tabel is gedimensioneerd. Omslaande offsets zouden de ergste manier zijn om te falen: een viewer die het hele bestand downloadt kan de hints negeren, terwijl de byte-range-client naar de verkeerde bytes springt, dus desktop-testing reproduceert de fout nooit. Voor output van meerdere gigabytes is de streamingroute in de Direct File API voor grote PDF-workflows de plek om te kijken

Controleren of een inkomend bestand gelineariseerd is

THotPDF.IsLoadedLinearized meldt of het geladen document zichzelf als gelineariseerd declareert. De controle gebeurt vóór het parsen: HotPDF leest tot 1024 bytes vanaf positie nul van de bron, vindt het eerste object, en zoekt daarin naar /Linearized met de waarde 1. Comments, literalen en hex-strings worden overgeslagen, dus de naam die binnen een string opduikt telt niet mee, en de scan stopt bij stream of endobj. Alleen 1 of een equivalent zoals 1.0 wordt geaccepteerd

Het antwoord wordt bij het laden gecached, want tegen de tijd dat applicatiecode het vraagt heeft de parser de streampositie verplaatst en heeft LoadFromFile zijn bronstroom al weer vrijgegeven

De flag meldt wat het bestand claimt. Annex F voegt een tweede voorwaarde toe: /L moet matchen met de echte bestandslengte, en een incrementele update die na de linearisatie wordt aangeplakt breekt die match, dus lezers vallen terug op het bestand als gewoon behandelen. Een pijplijn die bestanden van editors accepteert die hun saves aanplakken kan de twee getallen zelf vergelijken

// Eigen helper, geen deel van HotPDF: vergelijkt /L met de
// ware grootte; hij neemt één spatie na /L aan
function DeclaredLengthMatches(const FileName: string): Boolean;
var
  FS: TFileStream;
  Head: AnsiString;
  ActualSize: Int64;
  P, Q: Integer;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    ActualSize := FS.Size;
    SetLength(Head, 1024);
    SetLength(Head, FS.Read(Head[1], 1024));
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/L '), Head);
  Result := P > 0;
  if not Result then
    Exit;
  Inc(P, 3);
  Q := P;
  while (Q <= Length(Head)) and (Head[Q] in ['0'..'9']) do
    Inc(Q);
  Result := StrToInt64Def(string(Copy(Head, P, Q - P)), -1) = ActualSize;
end;

procedure InspectUpload(const FileName: string);
var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    if PDF.LoadFromFile(FileName) <= 0 then
      Exit;
    if not PDF.IsLoadedLinearized then
      Writeln('Not linearized')
    else if not DeclaredLengthMatches(FileName) then
      Writeln('Linearized, but modified after linearization')
    else
      Writeln('Ready for Fast Web View');
  finally
    PDF.Free;
  end;
end;

LinearizeOutput en IsLoadedLinearized maken deel uit van de HotPDF Delphi Component voor Delphi en C++Builder, en zijn productpagina documenteert hoe ze samenhangen met cross-reference-streams, object streams en version locking