Teknisk artikel

Bandad PDF-rendering i Delphi: negativ Y-offset

Det första bandet innehöll hela teckningen ihoptryckt till en enda remsa, och de fem banden efter det kom tillbaka tomma. Det var den gamla bandade exporten, och PDFiumPas rättade den i v3.66.0: RenderPageBanded skickar nu målets fulla sidbredd och höjd till FPDF_RenderPageBitmap på varje enskilt band tillsammans med en negativ vertikal offset, så att den inbyggda klippningen bara skriver raderna i det aktuella bandet medan sidan behåller sin fullständiga koordinatgeometri. Användningsfallet bakom allt detta är tråkigt och oundvikligt. Någon lämnar över en E-storlekstegning eller en hopfogad panoramasida och vill ha ett raster av den med 600 DPI. Ett ISO A0-ark vid 600 DPI är 19866 x 28086 pixlar, och en 32-bitars destinationsbitmap av den storleken är lite över 2 GB sammanhängande minne. I 32-bitars Delphi misslyckas allokeringen helt enkelt. I 64-bitars lyckas den tillräckligt ofta för att göra felet till ett kundproblem i stället för ett testproblem. Bandad rendering finns för att toppallokeringen ska vara en remsa, inte en hel sida

Varför innehöll varje band hela sidan?

Den gamla koden blandade ihop två olika par av argument i PDFiums sidrenderingsanrop. FPDF_RenderPageBitmap tar start_x, start_y, size_x och size_y, där storleksparet anger hur stor hela sidan ska skalas till och startparet anger var den skalade sidan hamnar inne i destinationsbitmapen. Loopen före v3.66.0 anropade bibliotekets hjälpare RenderPage med bandets övre kant som destinationsoffset och bandhöjden som sidhöjd. De två värdena gick direkt genom till det inbyggda anropet, så PDFium skalade hela sidan till en rektangel som bara var BandHeight rader hög och ritade den sedan vid y = BandTop inne i en bitmap som själv bara var BandHeight rader hög. Resultatet var exakt det man förutspår när man ser det. Band noll fick hela sidan vertikalt ihoptryckt till bandhöjden. Varje senare band fick samma ihoptryckta sida förskjuten under bitmapens nederkant, så det kom tillbaka som bakgrundsfyllning. Buggen gömmer sig i det enda fall som de flesta röktester använder, en sida vars renderingshöjd är mindre än bandhöjden, eftersom det då finns ett enda band och den felaktiga geometrin råkar sammanfalla med den rätta. Allt som är högre än ett band avslöjar den omedelbart

Vad garanterar den negativa offseten?

Den rättade implementationen skickar varje band genom RenderTile, som är den enda platsen i komponenten som redan förstod skillnaden. RenderTile tar ett plattursprung i fullständiga sidpixlars koordinater plus en separat PageWidth och PageHeight, och skickar -Left och -Top till PDFium med sidstorleken orörd. Genom att negera offseten skjuts den fullstora sidan uppåt tills det begärda bandet ligger på rad noll i destinationsbitmapen; PDFium klipper sedan inbyggt mot bitmapens gränser, så ingenting utanför bandet rasteriseras någonsin. Sid-till-enhet-mappningen som beskrivs i ISO 32000-1 klausul 8.3.2 förblir identisk från det första bandet till det sista, vilket är hela poängen: band N är bitidentiskt med raderna BandTop till och med BandTop + h i en enda fullsiderendering, och regressionssviten hävdar exakt det, pixel för pixel, mot utdata från RenderPage med samma dimensioner

// Ett band för hand. Destinationsbitmapen är bara BandHeight hög,
// men målets sidstorlek förblir hela Width x Height
Band := Pdf.RenderTile(0, BandTop,          // bandursprung i sidpixlar
                       Width, BandHeight,   // destinationsbitmapens storlek
                       Width, Height);      // målets fulla sidstorlek
try
  // Band innehåller nu raderna BandTop .. BandTop + BandHeight - 1 på sidan
finally
  Band.Free;
end;

Det publika band-API:et är en callback-loop. RenderPageBanded(Width, Height, BandHeight, BandCallback, Rotation, Options, Color) returnerar antalet band den faktiskt renderade, eller 0 när argumenten avvisas, och håller komponentens renderingslås under hela passet. Callback-signaturen är TPdfBandCallback = function(BandIndex, BandTopY: Integer; Bitmap: TBitmap): Boolean of object. Bitmapen är pf32bit, Width pixlar bred och högst BandHeight hög, och den frigörs så snart din hanterare returnerar, så kopiera allt du tänker behålla. Att returnera False stoppar passet efter det aktuella bandet, vilket ger samma samverkande avbrytningsmodell som används av avbrytbar progressiv PDF-rendering i Delphi, men på remsnivå i stället för PDFium-fortsättningsnivå

type
  TBandSink = class
  private
    FCancelled: Boolean;
    FRows: Integer;
  public
    function HandleBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean;
    property Rows: Integer read FRows;
  end;

function TBandSink.HandleBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  // Bitmapen dör när denna metod returnerar - konsumera den här
  Inc(FRows, Bitmap.Height);
  Result := not FCancelled;
end;

// ...
Pdf.PageNumber := 1;
Bands := Pdf.RenderPageBanded(19866, 28086, 256, Sink.HandleBand);

Strömma PNG och TIFF utan en fullsidig bitmap

Rendering i band hjälper bara om kodaren också är sekventiell, så v3.66.0 lade till RenderPageBandedToStream, som skriver PNG eller TIFF direkt till en ström som anroparen äger. TPdfBandedImageStreamOptions.Default startar med en bandhöjd på 256 rader, PNG-komprimeringsnivå 6 och MaxOutputBytes på 0, vilket betyder obegränsat. Den returnerade TPdfBandedImageReport bär Format, Width, Height, BandsRendered, BandsEncoded, RowsEncoded, PeakBandBytes, OutputBytes och Completed. PeakBandBytes är talet du faktiskt bryr dig om när ett jobb dimensioneras: det är Width * BandHeight * 4, så A0-arket ovan når ungefär 19 MB bandbuffert i stället för 2 GB sidbuffert

PNG-kodaren är avsiktligt smal. Den avger fast RGB8, skriver en IHDR med bitdjup 8 och färgtyp 2, bygger sedan varje skanningsrad med filtertyp 0 (ISO/IEC 15948 filtermetod 0, filtertyp None) och skickar den genom plattformens zlib-komprimeringsström. De komprimerade bytena kommer ut som CRC-försedda IDAT-chunkar skrivna i ordning. Den intressanta begränsningen är strömmen under deflate-lagret: den svarar på positionsfrågor eftersom komprimeringsströmmen frågar efter dem, men varje försök till en riktig seek ger ett fel. Det är avsiktligt. När en IDAT-chunk och dess CRC väl finns på tråden finns ingen väg tillbaka för att rätta dem, och en tyst seek skulle förstöra utdata som fortfarande ser strukturellt giltig ut

TIFF-kodaren skriver little-endian klassisk TIFF, byteordningsmarkören II följd av magiska 42, med en remsa per band. Pixlar strömmas ut först och den tiopostiga IFD:n genereras i slutet, när remsoffset och byteantal är kända. Komprimeringen är tagg 259 med värde 1, så ingen entropikodning alls används: nyttolasten är exakt Width * Height * 3 byte, PhotometricInterpretation är RGB, PlanarConfiguration är chunky och RowsPerStrip registrerar bandhöjden medan den sista korta remsan beskrivs av sin egen StripByteCounts-post. Bandhöjden ändrar alltså toppminne och antal remsor men inte utdatas storlek, vilket är värt att veta innan du finjusterar den. Om du vill ha små filer snarare än förlustfria sådana är vägen per sida i att konvertera PDF-sidor till JPEG-bilder med PDFium VCL-komponenten fortfarande det bättre verktyget

var
  StreamOptions: TPdfBandedImageStreamOptions;
  Report: TPdfBandedImageReport;
  Output: TFileStream;
begin
  StreamOptions := TPdfBandedImageStreamOptions.Default(pbifPng);
  StreamOptions.BandHeight := 512;
  StreamOptions.CompressionLevel := 6;
  StreamOptions.MaxOutputBytes := Int64(256) * 1024 * 1024;

  Output := TFileStream.Create('sheet-a0-600dpi.png', fmCreate);
  try
    Report := Pdf.RenderPageBandedToStream(Output, 19866, 28086,
      StreamOptions);
  finally
    Output.Free;
  end;

  if not Report.Completed then
    raise Exception.Create('Banded export stopped before the last row');
  // Report.PeakBandBytes = 19866 * 512 * 4, inte 19866 * 28086 * 4
end;

Var slutar en bandad export?

Två tak begränsar utdata, och de fallerar på olika ställen avsiktligt. Det första är anroparens budget: MaxOutputBytes upprätthålls av en begränsad skrivström som ger EPdfError före varje skrivning som skulle korsa gränsen, så budgeten är ett hårt tak i stället för en rapport i efterhand. Det andra är strukturellt. Klassisk TIFF lagrar remsoffset som 32-bitarsvärden, så BeginImage validerar Width * Height * 3 plus huvud och katalog mot den gränsen och avvisar jobbet innan en enda pixel skrivs; samma kontroll körs mot MaxOutputBytes i förväg, eftersom en TIFF vars budget inte ens täcker den egna pixellasten inte är värd att starta. PNG har ingen motsvarande gräns, eftersom IDAT-chunkar är helt sekventiella och ingen 32-bitars offsettabell kan svämma över

Var klar över vad en stoppad export lämnar efter sig. När passet inte når den sista raden förblir Completed False och kodaren rivs ner med EndImage(False), som avsiktligt skriver varken PNG:s IEND-chunk eller TIFF:s IFD. Den partiella filen är därför ogiltig och varje avkodare kommer att säga det, i stället för att vara en plausibel bild med saknade rader. Den städningen är omsluten så att ett sekundärt fel inne i EndImage inte kan ersätta originalundantaget, vilket är skillnaden mellan en stack trace som namnger den verkliga orsaken och en som namnger städaren. Om du behöver framsteg som överlever kan du checkpointa per band i din egen callback; teknikerna för remsbaserad cachelagring i PDFium Delphis guide till renderingscache och zoom gäller här också

Anslut din egen codec

När PNG och TIFF inte är målet tar RenderPageBandedToEncoder en ättling till TPdfBandedImageEncoder och driver samma loop. Livscykeln är uttrycklig och kort: BeginImage(Width, Height), sedan WriteBand(BandIndex, BandTopY, Bitmap) en gång per remsa i strikt stigande ordning och slutligen EndImage(Completed), med GetBytesWritten som matar Report.OutputBytes. De inbyggda kodarna avvisar direkt ett band i fel ordning i stället för att försöka buffra det, och varje kodare du skriver bör göra samma sak, eftersom en codec som tyst ordnar om remsor producerar en fil som öppnas och ljuger. Detta är skarven att använda för JPEG 2000-tiles, en JPEG-skrivare som matas med en MCU-rad i taget eller ett direkt inflöde till en utskriftsspole

type
  TCodecBandEncoder = class(TPdfBandedImageEncoder)
  private
    FNextBand: Integer;
    FWritten: Int64;
  public
    procedure BeginImage(Width, Height: Integer); override;
    function WriteBand(BandIndex, BandTopY: Integer;
      Bitmap: TBitmap): Boolean; override;
    procedure EndImage(Completed: Boolean); override;
    function GetBytesWritten: Int64; override;
  end;

function TCodecBandEncoder.WriteBand(BandIndex, BandTopY: Integer;
  Bitmap: TBitmap): Boolean;
begin
  if BandIndex <> FNextBand then
    raise EPdfError.Create('Bands must arrive in order');
  Bitmap.PixelFormat := pf32bit;
  // Mata Bitmap.ScanLine[0 .. Bitmap.Height - 1] till codecen här
  Inc(FNextBand);
  Result := True;
end;

En korskompilatorfälla som är värd att känna till

Zlib-enheten stavas olika i varje stödd verktygskedja: Delphi XE5 och senare använder System.ZLib, FPC använder zstream och äldre Delphi använder vanlig ZLib. Det är vanlig villkorlig kompilering. Fällan är att alla tre exporterar komprimeringsnivåkonstanter med namnen clNone och clDefault, som kolliderar rakt med TColor-medlemmarna med samma namn i grafik-enheten. När zlib-enheten dyker upp i implementationens uses-sats kan en okvalificerad clNone i renderingskod lösas till en komprimeringsnivå i stället för en färg utan någon diagnostik. PDFiumPas låser detta med explicita färgsentinelalias, PdfGraphicsColorNone och PdfGraphicsColorDefault, bundna en gång till de fullt kvalificerade grafikkonstanterna och använda överallt där en renderingsbakgrund eller färgschemasentinel jämförs. Tre kodrader, och symbolupplösningen slutar driva mellan kompilatorerna

Bandad rendering ser ut som en bekvämlighetsfunktion ända tills du möter sidan som inte får plats i RAM, och då är det den enda vägen som fungerar. Den rättade bandgeometrin, de sekventiella PNG- och TIFF-kodarna och skarven för egna kodare levereras alla som en del av PDFium Delphi-komponenten, med den fullständiga band-mot-sida-pixeljämförelsen körd i regressionssviten över Delphi, Lazarus och C++Builder