Technisch artikel

Hoe PDF-afbeeldingen (graphics) werken: contentstromen en operators

Een PDF-pagina slaat geen pixels op, en het slaat geen boom van vormobjecten op zoals SVG dat doet. Het slaat een programma op. Elke lijn, curve, vulling en geplaatste afbeelding op de pagina is het resultaat van het uitvoeren van een reeks operators in een contentstroom, van boven naar beneden, tegen een lopende grafische status. Begrijp dat ene feit en de meeste gedragingen van het formaat zijn niet langer verrassend: waarom een vulling een aparte tekenoperator nodig heeft nadat het pad is gebouwd, waarom kleuren en lijndiktes weglekken van de ene vorm naar de volgende, tenzij je ze groepeert (bracket), waarom dezelfde tekencode op totaal verschillende plaatsen kan belanden na een enkele coördinatentransformatie. Dit is een rondleiding door dat uitvoeringsmodel zoals gedefinieerd in ISO 32000: de operators die je tegenkomt wanneer je een contentstroom opent, en de regels die bepalen wat er op de pagina verschijnt

De contentstroom is postfix bytecode

Een contentstroom is een platte bytereeks van operanden gevolgd door operators. Operanden komen eerst, de operator die ze consumeert komt als laatste, wat het omgekeerde is van een functieaanroep en identiek is aan een stackmachine: push de getallen, geef dan het werkwoord uit. Er is geen nesten, geen expressiesyntaxis, geen variabelen. Een driehoeksomtrek is vijf regels hiervan:

100 100 m    % moveto: begin een nieuw subpad op (100, 100)
200 200 l    % lineto: add a segment to (200, 200)
300 100 l    % lineto: add a segment to (300, 100)
h            % closepath: verbind terug naar het begin
S            % stroke: teken de omtrek van het pad

De operators zijn opzettelijk beknopt. Een echte pagina bestaat uit duizenden hiervan, meestal gecomprimeerd met FlateDecode. De prijs van die compactheid is dat de stroom geen structuur bevat die je kunt opvragen (query): een viewer kan niet vragen "waar is de koptekst op deze pagina," het kan alleen het programma uitvoeren en zien welke inkt waar landt. Dat is de diepste reden waarom tekstextractie uit willekeurige PDF's moeilijk is

De oorsprong is linksonder, en Y groeit omhoog

Voordat een coördinaat logisch is, moet je weten waar (0, 0) is. PDF plaatst de oorsprong in de linkerbenedenhoek van de pagina, waarbij X toeneemt naar rechts en Y toeneemt naar boven, gemeten in punten met 72 punten per inch (ISO 32000-2 §8.3.2). Op een US Letter-pagina bevindt de bovenrand zich op y = 792, niet op y = 0. Iedereen die vanuit schermgrafiek arriveert, waar de oorsprong linksboven is en Y naar beneden groeit, doet dit bij de eerste poging achterstevoren en tekent de eerste regel buiten de onderkant van de pagina. De eenheid is ook onafhankelijk van het medium: 72 eenheden is één inch, ongeacht of de pagina wordt weergegeven op een telefoonscherm of een belichter (imagesetter)

PDF-diagram van het PDF-coördinatensysteem met oorsprong linksonder en Y omhoog groeiend, afgezet tegen de linksboven-schermconventie die identieke coördinaten nabij de onderkant van de pagina tekent
PDF meet vanuit linksonder met Y omhoog groeiend, en daarom parkeer je met schermgewoonten je eerste tekening onderaan de pagina

De meeste bibliotheken voor het tekenen van pagina's nemen deze conventie rechtstreeks over. In HotPDF meten bijvoorbeeld TextOut en de padaanroepen allemaal vanaf linksonder in punten, dus een waarde in de buurt van de paginahoogte plaatst de inhoud bovenaan:

// HotPDF, Delphi: y wordt vanaf de onderrand omhoog gemeten, in points
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700);   // vlak bij de bovenkant van de pagina
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke;             // geeft de moveto/lineto/stroke-operators uit

Die aanroepenreeks wordt gecompileerd naar exact de m-, l- en S-operators hierboven. De bibliotheek is een typiste voor de contentstroom, niets meer, en wetende wat het uitzendt, stelt je in staat om te redeneren over de uitvoer wanneer een vorm ergens landt waar je het niet had verwacht

Bouw het pad, schilder het daarna

PDF scheidt padconstructie van padschilderen, en de scheiding is geen muggenzifterij (pedantry). Je beschrijft eerst een vorm met constructie-operators die niets zichtbaars toevoegen, en geeft vervolgens één enkele teken-operator (painting operator) uit die bepaalt wat er moet gebeuren met het geaccumuleerde pad. Dezelfde driehoek kan een omtrek, een effen vulling, of beide zijn, en dit hangt alleen af van het werkwoord waarmee je eindigt

De constructie-operators zijn schaars. m begint een nieuw subpad op een punt. l voegt een recht segment toe. c voegt een kubische Bezier-curve (cubic Bezier curve) toe uit zes operanden, twee controlepunten en een eindpunt. re is een snelkoppeling die een hele rechthoek toevoegt uit een viervoud (quadruple) van x, y, breedte, hoogte. h sluit het huidige subpad terug naar het begin. Geen van hen plaatst inkt op de pagina; ze accumuleren alleen geometrie

200 250 m                    % begin het subpad
300 350 400 450 500 250 c    % kubische bezier: twee controlepunten, dan het eindpunt
150 200 re                   % een rechthoek van 150 x 200, als eigen subpad toegevoegd
h                            % close

Het oorspronkelijke voorbeeld gebruikte de nu verouderde y-variant van de curve-operator; c met zijn drie expliciete punten is de vorm die je in de praktijk zult zien en degene waar je naar moet grijpen. Zodra het pad bestaat, voltooit één tekenoperator het. De woordenschat is klein en het onthouden waard, omdat elke vorm op elke pagina eindigt met een van deze:

PDF-diagram van het PDF-padmodel waarin constructieoperators m, l, c, re en h onzichtbare geometrie accumuleren en één afsluitende operator S, f, f*, B of n beslist over stroke, fill, fill plus stroke of clip
Pad-operatoren verzamelen alleen geometrie, en het ene werkwoord dat het pad sluit beslist of je een outline, een vulling, beide of een clip krijgt
  • S tekent de omtrek van het pad met de huidige lijndikte en lijnkleur
  • f vult de binnenkant met de huidige vulkleur en de nonzero winding-regel
  • f* vult met de even-oneven-regel (even-odd rule), wat belangrijk is voor zichzelf snijdende vormen en vormen met gaten
  • B vult en tekent lijnen in één bewerking; b sluit het pad eerst
  • n schildert niets, wat de manier is waarop een pad een clip-gebied (clip region) wordt zonder een zichtbare markering achter te laten

De winding-regel is het deel dat mensen fout doen. Nonzero (f, B) telt de getekende (signed) kruisingen van een straal vanaf het testpunt en vult overal waar de telling niet nul is, dus een gat blijft alleen leeg als zijn subpad tegengesteld windt aan het buitenste. Even-oneven (f*, B*) schakelt bij elke kruising, ongeacht de richting. Als een "donut"-vorm effen uitvalt, is de binnenste cirkel op dezelfde manier gewonden als de buitenste, en moet je deze omdraaien of overschakelen naar even-oneven

Kleur is een modus, geen parameter

Kleur in een contentstroom is vasthoudend (sticky). Je stelt een kleur in en deze blijft ingesteld totdat je een andere instelt of een eerdere status herstelt, wat de reden is dat een ongegroepeerde kleurverandering stilletjes alles kleurt wat erna wordt getekend. PDF houdt vulkleur en lijnkleur ook als twee onafhankelijke instellingen, met kleine letters voor operators voor vullen en hoofdletters voor lijnen. De apparaatkleurruimten hebben elk hun eigen steno:

0.5 g                % DeviceGray fill, mid gray (0 = black, 1 = white)
0.2 0.6 0.8 rg       % DeviceRGB fill
0.8 0.2 0.1 RG       % DeviceRGB stroke (uppercase = stroke)
0.2 0.8 0.0 0.1 k    % DeviceCMYK fill

DeviceRGB is geschikt voor schermuitvoer, DeviceCMYK is wat printproductie verwacht, en DeviceGray is de kleinste keuze voor monochrome inhoud. De apparaatruimten zijn handig maar ongekalibreerd: dezelfde RGB-triple kan op twee monitoren verschillend renderen, wat het probleem is dat ICC-gebaseerde kleurruimten en PDF/A-uitvoerintenties (output intents) oplossen. Voor kleurkritisch werk selecteer je een gekalibreerde ruimte met cs en CS en stel je componenten in met sc en scn, maar voor gewone documenten dragen de apparaatsteno's de last. Een bibliotheek wikkelt deze in getypeerde aanroepen. HotPDF neemt bijvoorbeeld een enkele TColor en geeft de bijbehorende operators uit:

Pdf.CurrentPage.SetRGBFillColor(clRed);
Pdf.CurrentPage.Rectangle(100, 100, 200, 150);  // x, y, width, height
Pdf.CurrentPage.Fill;

Pdf.CurrentPage.SetRGBFillColor(RGB(0, 255, 0));
Pdf.CurrentPage.Circle(150, 400, 50);           // x, y, radius
Pdf.CurrentPage.Fill;

De grafische status en de q/Q-stapel

Alles wat niet het pad zelf is, bevindt zich in de grafische status: de huidige transformatiematrix, vul- en lijnkleuren, lijndikte, streeppatroon, clip-gebied, alpha. De status is globaal en muteerbaar (mutable), dus de enige veilige manier om een lokale wijziging aan te brengen is om het geheel op te slaan, te wijzigen, te tekenen en het terug te draaien. Dat is wat q en Q doen. q pusht een kopie van de huidige status op een stapel; Q popt het, waarbij elke wijziging die is aangebracht sinds de bijbehorende q wordt weggegooid

q                    % bewaar de complete graphics state
2 0 0 2 100 100 cm   % concatenate a transform: scale 2x, translate to (100,100)
0.8 g                % grijze vulling, alleen binnen dit blok
% ... draw scaled, gray content ...
Q                    % restore: transform en kleur gaan terug

Ongebalanceerde q en Q zijn een veelvoorkomende manier waarop een handgebouwde of aaneengestikte (stitched) contentstroom fout gaat. Een verdwaalde q zonder bijbehorende Q laat de stapel diep achter wanneer de pagina eindigt; een extra Q veroorzaakt een underflow. Hoe dan ook, een viewer kan een oude clip of transformatie van kracht houden, en inhoud verdwijnt of belandt op de verkeerde plaats. Wanneer afbeeldingen verdwijnen om geen reden die het pad kan verklaren, controleer dan eerst de statusstapel (state stack)

De CTM transformeert elke coördinaat

De huidige transformatiematrix (CTM) zit tussen de getallen in je operators en de eigenlijke pagina in. Elke coördinaat wordt vermenigvuldigd met de CTM voordat er iets wordt getekend, dus het veranderen van de matrix verandert waar en hoe alle daaropvolgende tekeningen verschijnen, zonder dat ook maar één padcoördinaat wordt aangeraakt. De cm-operator voegt (concatenates) een nieuwe matrix samen met de huidige, met zes operanden die verwijzen naar de affiene matrix (affine matrix) [a b c d e f]:

1 0 0 1 100 50 cm        % translate met (100, 50): e en f dragen de verschuiving
2 0 0 1.5 0 0 cm         % schaal x met 2, y met 1.5: a en d zijn de schaalfactoren
0.707 0.707 -0.707 0.707 0 0 cm   % rotate 45 degrees (cos/sin in a, b, c, d)

Twee dingen brengen mensen in de war. Ten eerste, cm stelt samen in plaats van te vervangen, dus transformaties stapelen zich op en de volgorde is belangrijk: eerst schalen en dan vertalen is niet hetzelfde als vertalen en dan schalen. Ten tweede draaien rotatie en schaling (scaling) rond de huidige oorsprong, niet rond het midden van je vorm, dus om iets op zijn plaats te roteren, vertaal je het naar de oorsprong, roteer je het, en vertaal je het vervolgens terug, allemaal verpakt in q/Q. Dezelfde matrix is wat afbeeldingen plaatst, het laatste onderdeel dat de moeite van het bekijken waard is

Afbeeldingen en herbruikbare content zijn XObjects

Rasterafbeeldingen (Raster images) leven niet inline in de contentstroom. Ze worden opgeslagen als image XObjects, externe objecten met hun eigen dictionary die breedte, hoogte, bitdiepte, kleurruimte en compressiefilter beschrijft, en de contentstroom verwijst er alleen naar. Een foto op basis van JPEG declareert zichzelf als volgt:

/Photo <<
  /Type /XObject
  /Subtype /Image
  /Width 640
  /Height 480
  /BitsPerComponent 8
  /ColorSpace /DeviceRGB
  /Filter /DCTDecode        % de image data is een JPEG-stream
>>

Een image XObject tekent in het eenheidsvierkant (unit square): het neemt altijd het gebied van (0, 0) tot (1, 1) in de gebruikersruimte (user space) in. Je geeft er geen positie of grootte aan door. In plaats daarvan stel je de CTM in zodat dat eenheidsvierkant (unit square) overeenkomt met de rechthoek die je wilt, waarna je het aanroept met Do. Daarom is het plaatsen van een afbeelding altijd een transformatie gevolgd door een aanroep, verpakt in een save/restore, zodat de schaal niet overvloeit (bleed) naar de volgende bewerking:

PDF-diagram van PDF image XObject-plaatsing waarbij het woordenboek pixels definieert buiten de content stream, Do in het eenheidsvierkant schildert, en cm het afbeeldt op de doelrechthoek verpakt in q en Q
De stream bewaart geen pixels — hij roept een XObject op in de eenheidsvierkant, en cm brengt dat vierkant in kaart op de doelrechthoek
q
640 0 0 480 50 300 cm    % map het eenheidsvierkant op een vak van 640x480 op (50, 300)
/Photo Do                % teken het image XObject
Q

Hetzelfde Do-mechanisme drijft form XObjects aan, die een herbruikbaar stuk afbeeldingen bevatten, een logo of een herhaalde stempel, als hun eigen contentstroom met een bounding box (omvattende rechthoek). Definieer het eenmaal, roep het vele malen aan met een andere CTM, en de bytes verschijnen slechts één keer in het bestand. De meeste bibliotheken verbergen dit achter een enkele plaatsingsoproep: HotPDF registreert een bitmap met AddImage en plaatst deze met ShowImage, waarbij een expliciete x, y, breedte en hoogte wordt aangenomen in plaats van je te vragen de matrix met de hand te bouwen:

var
  Bmp: TBitmap;
  ImgIndex: Integer;
begin
  Bmp := TBitmap.Create;
  try
    Bmp.LoadFromFile('logo.bmp');
    ImgIndex := Pdf.AddImage(Bmp, icFlate);
    // x, y (bottom-left), width, height, rotation angle
    Pdf.CurrentPage.ShowImage(ImgIndex, 50, 300, 200, 150, 0);
  finally
    Bmp.Free;
  end;
end;

Onder die ene regel schrijft de bibliotheek de image XObject-dictionary, stelt de CTM in om de grootte en positie van het eenheidsvierkant in te stellen, en geeft Do uit. Het onderliggende model is het weten waard, omdat het elk vreemd resultaat verklaart: een uitgerekte afbeelding is een CTM met niet-overeenkomende schaalfactoren, een logo dat op veertig pagina's identiek is, is één form XObject dat veertig keer is aangeroepen, en een afbeelding die ondersteboven wordt weergegeven is een tekenomkering (sign flip) in de matrix, geen corrupt bestand

Waar dit toe leidt

Het grafische model is klein zodra je de vorm ervan ziet. Een contentstroom is postfix bytecode die wordt uitgevoerd op een muteerbare status; coördinaten beginnen linksonder en gaan door de CTM; paden worden stilzwijgend gebouwd en geschilderd met één doelbewuste operator; kleur- en lijninstellingen blijven bestaan totdat je ze groepeert (bracket) met q/Q; afbeeldingen en herbruikbare grafische weergaven zijn XObjects die geplaatst worden door een eenheidsvierkant te transformeren. Bijna elk verwarrend weergave-resultaat is terug te brengen tot een van die vijf regels. Als je wilt zien hoe deze grafische operators binnen het grotere objectmodel passen, de paginadictionaries en de kruisverwijzingstabel die ernaar verwijzen, de technisch overzicht van de PDF-bestandsstructuur dekt die laag, en het bouwen van een eenvoudige PDF vanaf nul doorloopt de bytes van begin tot eind. Het tekenen van tekst leeft in zijn eigen operatorfamilie en heeft zijn eigen valkuilen, die worden behandeld in het begeleidende stuk over PDF-tekst en het verwerken van lettertypen

De Delphi-tekenaanroepen die hier worden weergegeven, MoveTo, LineTo, Stroke, Rectangle, Fill, SetRGBFillColor, AddImage en ShowImage, maken deel uit van de HotPDF Delphi Component voor Delphi en C++Builder, dat deze contentstroom-operators voor je uitzendt