Ga naar inhoud

Streams en filters

Evidence: Standard-backed

De meeste bytes in een echte PDF zitten in streams: pagina-inhoud, lettertypen, afbeeldingen en de cross-reference stream zelf. Vrijwel geen van die bytes wordt onbewerkt opgeslagen; ze gaan eerst door een of meer filters. Deze pagina legt uit welke filters je tegenkomt, waarvoor ze dienen, waar ze problemen geven en waarom NextPDF zijn compressie vastpint, zodat dezelfde invoer altijd dezelfde bytes oplevert.

Een stream en zijn filter vormen een contract: “deze bytes zijn gecomprimeerd met deflate en daarna base-85-gecodeerd — decodeer ze in die volgorde om de echte gegevens te krijgen.” Als de /Filter-vermelding niet overeenkomt met wat de bytes daadwerkelijk zijn, als de /Length verkeerd is, of als twee filters in de verkeerde volgorde worden vermeld, is de stream niet te decoderen en gaat de inhoud die hij bevatte verloren. Een reader gokt niet heuristisch; hij volgt de dictionary.

Daar is nog een tweede, minder zichtbare prijs. Als de compressor van een bibliotheek niet-deterministisch is — een andere zlib-build, een ander niveau, andere interne blokgrenzen — leveren twee runs die een identieke PDF zouden moeten produceren, toch twee verschillende bestanden op. Dat doorbreekt reproduceerbaarheid op byteniveau. Verbroken reproduceerbaarheid breekt vervolgens golden-file-tests, verificatie van ondertekende builds en elke pijplijn die uitvoer vergelijkt. Filters bepalen dus zowel of de PDF correct is als of de PDF hetzelfde is.

  • Een stream-object is een dictionary plus een blok bytes, ingepakt tussen streamendstream, met een /Length en meestal een /Filter.
  • De /Filter-vermelding noemt het decodeerfilter — of een array met filters die als pijplijn in volgorde worden toegepast.
  • De filters vallen uiteen in twee families: compressie (FlateDecode, LZWDecode, RunLengthDecode, DCTDecode, JPXDecode, JBIG2Decode) en ASCII-transport (ASCIIHexDecode, ASCII85Decode), plus het speciale Crypt-filter voor versleuteling.
  • Het filter dat je het vaakst ziet, is FlateDecode — zlib/deflate. Het is de standaard voor inhoud, lettertypen en de cross-reference stream.
  • NextPDF pint zijn Flate-uitvoer op een vast niveau en formaat, zodat dezelfde invoerbytes altijd naar dezelfde uitvoerbytes comprimeren.

NextPDF schrijft stream-objecten via één buffer-helper en comprimeert via één vastgepinde compressor — met opzet.

BinaryBuffer::writeStream() (src/Support/BinaryBuffer.php) verpakt streaminhoud met de bijbehorende dictionary, schrijft altijd een /Length die gelijk is aan de werkelijke bytelengte en voegt eventuele extra vermeldingen samen die de aanroeper meegeeft, zoals /Filter. Er is geen codepad waarin de gedeclareerde lengte kan afwijken van de geschreven bytes, omdat de lengte rechtstreeks uit de inhoudsstring zelf wordt afgeleid.

Compressie verloopt via PinnedZlibCompressor (src/Writer/PinnedZlibCompressor.php). Deze klasse bestaat om één reden: gzcompress zonder expliciet niveau valt terug op de runtime-standaard van zlib, die historisch per build kon verschillen. De 2-byte zlib-header codeert het niveau zelfs indirect, dus “de standaard” levert geen stabiele uitvoer op. De compressor pint het niveau vast op het RFC 1951-maximum en levert altijd zlib-omhulde deflate op (RFC 1950-header + Adler-32-trailer), precies wat /Filter /FlateDecode verwacht. Een harde zlib-fout wordt een getypeerde exception in plaats van een stille terugval naar niet-gecomprimeerde uitvoer — een stream wordt nooit stilletjes onbewerkt weggeschreven.

De cross-reference stream zelf is een concreet voorbeeld van dit alles: CrossReferenceStream (src/Core/CrossReferenceStream.php) bouwt een binaire tabel, comprimeert die en schrijft die weg als stream-object met /Type /XRef, een /W-array met veldbreedtes en /Filter /FlateDecode. De index waarmee een reader elk object kan vinden, is zelf een gefilterde stream.

FilterFamilieWaarvoor het dientWaar het misgaat
FlateDecodeCompressiezlib/deflate; de standaard voor inhoud, lettertypen, xref-streamsEen niet-deterministische zlib-build kan “identieke” PDF’s byte voor byte laten verschillen
LZWDecodeCompressieOudere Lempel–Ziv–Welch-compressieVerouderd; vervangen door Flate, af en toe nog te zien in oude bestanden
DCTDecodeCompressieJPEG-gecodeerde kleur- of grijswaardenafbeeldingenLossy — een al-DCT-afbeelding opnieuw coderen verslechtert die nogmaals
JPXDecodeCompressieJPEG 2000-wavelet-beeldgegevensNiet toegestaan door sommige archiveringsprofielen; brede ondersteuning is wisselend
JBIG2DecodeCompressieBi-level (1-bit) beeldcompressieMag niet worden gebruikt met inline-afbeeldingen; lossy modi kunnen scans wijzigen
RunLengthDecodeCompressieBytegeoriënteerde run-length-compressieHelpt alleen bij gegevens met lange reeksen van één byte; kan andere gegevens juist groter maken
ASCIIHexDecodeTransportBinaire gegevens als hexadecimale cijfersVerdubbelt de omvang; alleen voor 7-bit-veilige kanalen, nooit om ruimte te besparen
ASCII85DecodeTransportBinaire gegevens als base-85-ASCII~25% overhead; handig voor transport, geen compressie
CryptBeveiligingPast de security handler van het document toeEen cross-reference stream mag geen Crypt-filter gebruiken

De standaardfilterset van PDF, per familie, met de faalwijze die bij elk filter hoort. NextPDF schrijft FlateDecode voor inhoud, lettertypen en de cross-reference stream; de ASCII-transportfilters zijn bedoeld voor 7-bit-kanalen, nooit om de omvang te verkleinen.

Het filtermechanisme wordt gedefinieerd door Spec: ISO 32000-2, §7.4 . Een stream-dictionary noemt zijn filters via /Filter. Wanneer de vermelding meer dan één filter bevat, vormen die filters een decodeerpijplijn en worden ze in volgorde toegepast. Een writer codeert een stream om die te comprimeren of 7-bit-veilig te maken. Een reader roept de bijbehorende decodeerfilters aan om de oorspronkelijke gegevens te herstellen. Evidence: Standard-backed

De filtertabel van de standaard deelt elk filter in. FlateDecode decomprimeert met zlib/deflate gecodeerde gegevens en reproduceert de oorspronkelijke tekst of binaire gegevens. DCTDecode reproduceert via JPEG beeldsamples die het origineel benaderen — het woord “benaderen” in de standaard vertelt je dat het lossy is. LZWDecode, RunLengthDecode, JBIG2Decode, JPXDecode en het Crypt-filter worden daar ook elk gedefinieerd, waarbij JBIG2 expliciet wordt verboden voor inline-afbeeldingen.

De cross-reference stream laat zien hoe het formaat zijn eigen mechaniek op zichzelf toepast: het is een stream-object (/Type /XRef, Spec: ISO 32000-2, §7.5.8 ) waarvan de /W-array de bytebreedte van elk vermeldingsveld in de gedecodeerde stream aangeeft. De standaard vereist dat hij niet versleuteld is en geen Crypt-filter gebruikt. De CrossReferenceStream van NextPDF houdt zich hier precies aan — FlateDecode, expliciete /W, geen versleuteling.

Een pagina-inhoudsstream, gecomprimeerd met Flate. Dit is veruit de meest voorkomende vorm: een dictionary met /Length en /Filter, gevolgd door de gecomprimeerde bytes tussen stream en endstream.

<?php
declare(strict_types=1);
use NextPDF\Writer\PinnedZlibCompressor;
// The marking operators a page content stream carries, uncompressed.
$content = "BT /F1 12 Tf 72 712 Td (Hello) Tj ET\n";
// NextPDF compresses through the pinned compressor: fixed level,
// fixed zlib-wrapped format. The same $content always yields the
// same $compressed bytes, on any supported PHP/zlib build.
$compressed = PinnedZlibCompressor::compress($content);
// Emitted as a stream object. /Length is the real byte length of
// $compressed; /Filter names the decode the reader must apply.
// N 0 obj
// << /Length <strlen($compressed)> /Filter /FlateDecode >>
// stream
// <$compressed bytes>
// endstream
// endobj

Een reader doet het omgekeerde: hij leest /Length bytes, haalt ze door FlateDecode omdat /Filter dat zegt, en krijgt de oorspronkelijke operatoren terug. Door de compressor vast te pinnen is die heen-en-terugweg niet alleen correct; hij is elke keer identiek. Daarop steunen golden-file-tests en controles op ondertekende builds.

De valkuil is dat je ASCII-filters als compressie behandelt. ASCIIHexDecode en ASCII85Decode maken een stream groter — respectievelijk ongeveer het dubbele en ongeveer 25%. Ze bestaan om binaire gegevens door een kanaal te verplaatsen dat alleen veilig is voor 7-bits tekst, niet om ruimte te besparen. Als je ASCII85 kiest om een PDF te “verkleinen”, doet dat het tegenovergestelde. De andere helft van hetzelfde misverstand is geloven dat FlateDecode voor afbeeldingen “gratis” lossless is. Flate is lossless, maar als de afbeelding al DCT (JPEG)-gecodeerd was, wordt die slechter wanneer je die opnieuw inpakt of via een lossy filter transcodeert, ongeacht wat Flate eromheen doet. De filterpijplijn behoudt precies wat je erin stopt — inclusief een hercompressie-artefact dat je er per ongeluk in hebt gestopt.

Deze pagina behandelt hoe filters worden gedeclareerd en toegepast, niet het algoritme op bitniveau in elk filter. De determinismegarantie betreft specifiek de Flate-uitvoer van NextPDF voor de streams die het schrijft. Die geldt over PHP-minorversies en standaardconforme zlib-builds heen, maar de standaard staat een deflate-encoder expliciet toe om andere interne blokgrenzen te kiezen, zodat bit voor bit gelijke uitvoer over werkelijk verschillende zlib-implementaties (bijvoorbeeld een standaard zlib versus zlib-ng) niet wordt beloofd. De build-omgeving is om die reden vastgepind.

NextPDF gebruikt FlateDecode en de ASCII-transportfilters voor de gegevens die het wegschrijft. Het is geen beeldtranscoder. Het belooft niet dat het een willekeurige binnenkomende JPEG2000- of JBIG2-stream opnieuw inpakt, en lossy compromissen in beeldkwaliteit zijn een eigenschap van de brongegevens, niet iets wat een writer ongedaan kan maken.

Waarom is FlateDecode overal? Het is lossless, breed bruikbaar, goed ondersteund en past goed bij de tekst- en operatorinhoud van de meeste PDF’s. Het is de veilige standaard voor inhoudsstreams, ingebedde lettertypen en de cross-reference stream.

Kan ik compressie uitschakelen? Je kunt /Filter weglaten en onbewerkte bytes opslaan; een reader accepteert dat. Het bestand wordt groter en verder verbetert er niets; buiten foutopsporing is er zelden een reden voor.

Waarom het compressieniveau überhaupt vastpinnen? Zodat de uitvoer reproduceerbaar is. Een niet-vastgepind niveau (of een andere zlib-build) kan de gecomprimeerde bytes wijzigen zonder de gedecomprimeerde inhoud te veranderen — correct, maar niet identiek; dat ondermijnt verificatie op byteniveau.

  • Stream-object — een dictionary plus een blok bytes tussen stream en endstream, met een /Length en meestal een /Filter.
  • Filter — een benoemde decodeertransformatie die een reader toepast op de bytes van een stream (bijvoorbeeld FlateDecode).
  • Filterpijplijn — een array van filters die in volgorde worden toegepast; de volgorde in de array is de decodeervolgorde.
  • FlateDecode — het zlib/deflate-filter; de standaardcompressie voor inhoud, lettertypen en cross-reference streams.
  • DCTDecode — het JPEG-beeldfilter; lossy, dus opnieuw coderen verslechtert de afbeelding nogmaals.
  • ASCII-transportfilter — ASCIIHexDecode / ASCII85Decode; maakt gegevens 7-bit-veilig ten koste van de omvang — geen compressie.
  • Deterministische compressie — bit voor bit gelijke gecomprimeerde uitvoer produceren voor identieke invoer, bereikt door het niveau en formaat van de compressor vast te pinnen.