Tekninen artikkeli

Kuinka PDF-grafiikka toimii: Sisältövirrat ja operaattorit

PDF-sivu ei tallenna pikseleitä, eikä se tallenna muoto-objektien puuta (tree of shape objects) samalla tavalla kuin SVG. Se tallentaa ohjelman. Jokainen viiva, käyrä, täyttö (fill) ja sijoitettu kuva sivulla on tulosta siitä, että sisältövirrassa (content stream) suoritetaan sarja operaattoreita ylhäältä alas, jatkuvaa grafiikkatilaa (running graphics state) vasten. Kun ymmärrät tämän yhden asian, suurin osa muodon käyttäytymisestä lakkaa olemasta yllättävää: miksi täyttö tarvitsee erillisen maalausoperaattorin polun (path) rakentamisen jälkeen, miksi värit ja viivanleveydet vuotavat (leak) yhdestä muodosta seuraavaan ellet rajaa (bracket) niitä, miksi sama piirtokoodi voi päätyä täysin eri paikkaan yhden ainoan koordinaatistomuunnoksen (coordinate transform) jälkeen. Tämä on kiertomatka tuohon ISO 32000 -standardissa määriteltyyn suoritusmalliin (execution model): operaattoreihin, jotka kohtaat, kun avaat sisältövirran, ja sääntöihin, jotka päättävät, mitä sivulle ilmestyy

Sisältövirta on postfix-tavukoodia (postfix bytecode)

Sisältövirta on litteä (flat) tavusarja (byte sequence) operandeja (operands), joita seuraavat operaattorit (operators). Operandit tulevat ensin, ja niitä kuluttava operaattori tulee viimeisenä; tämä on päinvastoin kuin funktiokutsu ja täysin identtinen pinokoneen (stack machine) kanssa: työnnä numerot pinoon (push), ja anna sitten verbi. Tässä ei ole sisäkkäisyyksiä (nesting), ei lausekesyntaksia (expression syntax), ei muuttujia. Kolmion ääriviiva (outline) on viisi riviä tätä:

100 100 m    % moveto: start a new subpath at (100, 100)
200 200 l    % lineto: add a segment to (200, 200)
300 100 l    % lineto: add a segment to (300, 100)
h            % closepath: connect back to the start
S            % stroke: paint the path outline

Operaattorit ovat ytimekkäitä (terse) tarkoituksella. Todellinen sivu sisältää tuhansia näitä, yleensä pakattuna FlateDecode:lla. Tämän tiiviyden hinta on se, että virta ei kanna mukanaan mitään rakennetta, jota voisit kysellä (query): katseluohjelma (viewer) ei voi kysyä "missä tämän sivun otsikko on", se voi vain suorittaa ohjelman ja katsoa, mihin muste osuu. Tämä on perimmäinen syy sille, miksi tekstin poimiminen (text extraction) satunnaisista PDF-tiedostoista on vaikeaa

Origo on vasemmassa alakulmassa ja Y kasvaa ylöspäin

Ennen kuin yksikään koordinaatti on järkevä, sinun on tiedettävä, missä (0, 0) on. PDF asettaa origon (origin) sivun vasempaan alakulmaan; X kasvaa oikealle ja Y kasvaa ylöspäin, mitattuna pisteinä (points) siten, että tuumassa on 72 pistettä (ISO 32000-2 §8.3.2). US Letter -kokoisella sivulla yläreuna sijaitsee kohdassa y = 792, ei y = 0. Kuka tahansa ruutugrafiikasta (screen graphics) saapuva, missä origo on vasemmassa yläkulmassa ja Y kasvaa alaspäin, saa tämän aluksi väärinpäin (backwards) ja piirtää ensimmäisen viivan sivun alareunan yli. Yksikkö on myös riippumaton mediasta (medium): 72 yksikköä on yksi tuuma, renderöityi sivu sitten puhelimen näytölle tai valolatomakoneelle (imagesetter)

Useimmat sivunpiirtokirjastot (page-drawing libraries) perivät (inherit) tämän käytännön suoraan. Esimerkiksi HotPDF:ssä TextOut ja polkukutsut mittaavat kaikki vasemmasta alakulmasta pisteinä, joten sivun korkeutta lähellä oleva arvo sijoittaa sisällön ylös:

// HotPDF, Delphi: y measured from the bottom edge upward, in points
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700);   // near the top of the page
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke;             // emits the moveto/lineto/stroke operators

Tämä kutsusarja (call sequence) kääntyy täsmälleen yllä oleviksi m-, l- ja S-operaattoreiksi. Kirjasto on vain konekirjoittaja sisältövirralle, ei muuta, ja sen tunteminen, mitä se tuottaa (emits), on se, mikä antaa sinun päätellä tulostetta (output) silloin, kun jokin muoto päätyy paikkaan, jota et odottanut

Rakenna polku, ja maalaa se sitten

PDF erottaa polun rakentamisen (path construction) polun maalaamisesta (path painting), eikä tämä erottelu ole pikkumaisuutta (pedantry). Kuvaat (describe) ensin muodon rakennusoperaattoreilla (construction operators), jotka eivät lisää mitään näkyvää, ja annat sitten yhden maalausoperaattorin (painting operator), joka päättää, mitä kerääntyneelle (accumulated) polulle tehdään. Sama kolmio voi olla ääriviiva (outline), yhtenäinen täyttö (solid fill) tai molemmat, riippuen ainoastaan siitä, mihin verbiin lopetat

Rakennusoperaattoreita on vain muutamia. m aloittaa uuden alipolun (subpath) tietyssä pisteessä. l lisää suoran segmentin. c lisää kuutio-Bézier-käyrän (cubic Bezier curve) kuudesta operandista, jotka muodostuvat kahdesta ohjauspisteestä (control points) ja päätepisteestä (endpoint). re on oikotie (shortcut), joka lisää kokonaisen suorakulmion nelikosta (quadruple) x, y, width ja height. h sulkee nykyisen alipolun takaisin sen alkuun. Mikään näistä ei laita mustetta sivulle; ne vain keräävät geometriaa

200 250 m                    % start the subpath
300 350 400 450 500 250 c    % cubic Bezier: two control points, then endpoint
150 200 re                   % a 150 x 200 rectangle, added as its own subpath
h                            % close

Alkuperäinen esimerkki käytti käyräoperaattorin nyt jo vanhentunutta (obsolete) y-varianttia; kolmen eksplisiittisen pisteen c on se muoto, jonka näet käytännössä ja johon kannattaa tarttua. Kun polku on olemassa, yksi maalausoperaattori viimeistelee sen. Sanasto on pieni ja arvoinen opetella ulkoa, sillä jokainen muoto jokaisella sivulla päättyy yhteen näistä:

  • S piirtää polun ääriviivan (strokes) käyttäen nykyistä viivanleveyttä (line width) ja viivaväriä (stroke color)
  • f täyttää (fills) sisäosan käyttäen nykyistä täyttöväriä (fill color) ja nonzero winding -sääntöä
  • f* täyttää käyttäen even-odd -sääntöä, millä on väliä itseään leikkaavissa muodoissa ja reiällisissä muodoissa (shapes with holes)
  • B täyttää ja sen jälkeen piirtää ääriviivat (strokes) yhdellä operaatiolla; b sulkee polun ensin
  • n ei maalaa mitään, ja näin polusta tulee leikealue (clip region) jättämättä näkyvää jälkeä

Winding-sääntö on se osa, jonka ihmiset tekevät väärin. Nonzero (f, B) laskee etumerkilliset (signed) ylitykset (crossings), kun testisäteeltä edetään pisteestä, ja täyttää siellä, missä laskuri ei ole nolla, joten reikä pysyy tyhjänä vain silloin, jos sen alipolku kiertää (winds) vastakkaiseen suuntaan kuin ulompi polku. Even-odd (f*, B*) kytkeytyy (toggles) jokaisella ylityksellä (crossing) suunnasta riippumatta. Jos "donitsi"-muodosta tulee yhtenäinen, sisäympyrä on kierretty (wound) samaan suuntaan kuin ulompi, ja sinun on joko käännettävä sen suunta tai vaihdettava even-odd -sääntöön

Väri on tila (mode), ei parametri

Väri sisältövirrassa on tahmea (sticky). Asetat värin ja se pysyy asetettuna kunnes asetat toisen tai palautat (restore) aiemman tilan, minkä vuoksi rajaamaton (unbracketed) värimuutos sävyttää (tints) hiljaisesti kaiken sen jälkeen piirretyn. PDF pitää myös täyttövärin (fill color) ja viivavärin (stroke color) kahtena itsenäisenä asetuksena, käyttäen pieniä kirjaimia täyttöön ja suuria kirjaimia viivaan. Laiteväriavaruuksilla (device color spaces) on kullakin oma lyhenteensä (shorthand):

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 sopii näytölle (screen output), DeviceCMYK on se mitä painotuotanto odottaa, ja DeviceGray on pienin valinta yksiväriselle (monochrome) sisällölle. Laiteväriavaruudet ovat käteviä mutta kalibroimattomia: sama RGB-kolmikko (triple) voi renderöityä eri tavalla kahdella monitorilla, mikä on se ongelma, jota ICC-pohjaiset väriavaruudet ja PDF/A output intent -määritykset ovat olemassa ratkaistakseen. Värin suhteen kriittiseen työhön valitset kalibroidun avaruuden (calibrated space) koodeilla cs ja CS ja asetat komponentit koodeilla sc ja scn, mutta tavallisille asiakirjoille laitevaihtoehdot (device shorthands) kantavat kuorman. Kirjasto käärii nämä tyypitettyihin kutsuihin (typed calls). Esimerkiksi HotPDF ottaa yhden TColor:n ja tuottaa (emits) vastaavat operaattorit:

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;

Grafiikkatila (graphics state) ja q/Q-pino (stack)

Kaikki mikä ei ole itse polkua (path), elää grafiikkatilassa (graphics state): nykyinen muunnosmatriisi (current transformation matrix, CTM), täyttö- ja viivavärit, viivanleveys (line width), katkoviivakuvio (dash pattern), leikealue (clip region), alpha. Tila on globaali ja muuttuva (mutable), joten ainoa turvallinen tapa tehdä paikallinen (local) muutos on tallentaa (save) se kokonaisuudessaan, muokata sitä, piirtää ja palauttaa (roll back) se. Sitä q ja Q tekevät. q työntää (pushes) kopion nykyisestä tilasta pinoon (stack); Q poimii (pops) sen pinosta hyläten kaikki muutokset, jotka on tehty vastaavan q:n jälkeen

q                    % save the entire graphics state
2 0 0 2 100 100 cm   % concatenate a transform: scale 2x, translate to (100,100)
0.8 g                % gray fill, scoped to this block
% ... draw scaled, gray content ...
Q                    % restore: transform and color revert

Epätasapainoiset (unbalanced) q ja Q ovat yleinen tapa, jolla käsintehty (hand-built) tai kursittu (stitched) sisältövirta (content stream) menee pieleen. Yksinäinen (stray) q ilman vastaavaa Q:ta jättää pinon syväksi (deep) sivun päättyessä; ylimääräinen Q tyhjentää (underflows) sen. Kummassakin tapauksessa katseluohjelma (viewer) voi pitää vanhan leikkeen (clip) tai muunnoksen (transform) voimassa, ja sisältö katoaa tai päätyy väärään paikkaan. Kun grafiikkaa häviää ilman mitään sellaista syytä, jonka polku (path) pystyisi selittämään, tarkista (audit) ensimmäisenä tilapino (state stack)

CTM muuntaa jokaisen koordinaatin

Nykyinen muunnosmatriisi (current transformation matrix, CTM) istuu operaattoriesi numeroiden ja todellisen sivun välissä. Jokainen koordinaatti kerrotaan CTM:llä ennen kuin mitään piirretään, joten matriisin muuttaminen muuttaa sitä, mihin ja miten kaikki sitä seuraava piirtäminen ilmestyy, koskematta yhteenkään polkukoordinaattiin. Operaattori cm liittää (concatenates) uuden matriisin nykyiseen, ottaen kuusi operandia, jotka mappautuvat (map) affiiniin matriisiin (affine matrix) [a b c d e f]:

1 0 0 1 100 50 cm        % translate by (100, 50): e and f carry the offset
2 0 0 1.5 0 0 cm         % scale x by 2, y by 1.5: a and d are the scale factors
0.707 0.707 -0.707 0.707 0 0 cm   % rotate 45 degrees (cos/sin in a, b, c, d)

Kaksi asiaa saa ihmiset kompastumaan. Ensimmäinen on se, että cm muodostaa kokonaisuuksia (composes) korvaamisen (replaces) sijaan, joten muunnokset kasautuvat (accumulate) ja järjestyksellä on väliä: skaalaus ja sen jälkeen siirto (translating) ei ole sama asia kuin siirto ja sen jälkeen skaalaus. Toiseksi, kierto (rotation) ja skaalaus (scaling) kääntyvät (pivot) nykyisen origon ympärillä, eivät muodostelmasi keskellä, joten kiertääksesi jotakin paikallaan sinun on siirrettävä se origoon, kierrettävä ja sitten siirrettävä takaisin, ja kaikki tämä käärittynä (wrapped) q/Q:n sisään. Tämä sama matriisi on se, joka sijoittaa kuvat, ja joka on viimeinen näkemisen arvoinen osa

Kuvat ja uudelleenkäytettävä sisältö ovat XObjecteja

Rasterikuvat (raster images) eivät asu sisältövirran (content stream) sisällä (inline). Ne tallennetaan kuva-XObjecteina (image XObjects), eli ulkoisina objekteina, joilla on oma sanakirjansa, joka kuvaa leveyden (width), korkeuden (height), bittisyvyyden (bit depth), väriavaruuden (color space) ja pakkaussuodattimen (compression filter), ja sisältövirta ainoastaan viittaa niihin. JPEG-pohjainen valokuva ilmoittaa itsensä näin:

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

Kuva-XObject piirtää yksikköneliöön (unit square): se varaa (occupies) aina alueen (0, 0) - (1, 1) käyttäjätilassa (user space). Et siirrä (pass) sille positiota (position) tai kokoa. Sen sijaan asetat CTM:n (current transformation matrix) siten, että yksikköneliö mappautuu (maps) haluamaasi suorakulmioon, minkä jälkeen kutsut (invoke) sen Do-operaattorilla. Siksi kuvan sijoittaminen (placing an image) on aina muunnos, jota seuraa kutsu (invocation), ja joka on kääritty (wrapped) save/restore-pariin, jotta skaalaus ei vuoda (bleed) seuraavaan operaatioon:

q
640 0 0 480 50 300 cm    % map the unit square to a 640x480 box at (50, 300)
/Photo Do                % paint the image XObject
Q

Sama Do-mekanismi ohjaa form XObjecteja (form XObjects), jotka pitävät sisällään uudelleenkäytettävän (reusable) palan (chunk) grafiikkaa, logon tai toistuvan leiman, omana sisältövirtanaan, jolla on rajauslaatikko (bounding box). Määrittele se kerran, kutsu (invoke) se monta kertaa eri CTM:llä, ja tavut ilmestyvät tiedostoon vain kerran. Useimmat kirjastot piilottavat tämän yhden sijoituskutsun (placement call) taakse: HotPDF rekisteröi bittikartan komennolla AddImage ja sijoittaa sen komennolla ShowImage, ottaen vastaan eksplisiittisen x, y, width ja height -arvon sen sijaan, että sinun tarvitsisi rakentaa matriisia käsin:

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;

Tuon yhden rivin alla kirjasto kirjoittaa kuva-XObject-sanakirjan (image XObject dictionary), asettaa CTM:n määrittämään yksikköneliön koon ja paikan ja tuottaa (emits) Do-komennon. Sen alla oleva malli on kuitenkin syytä tuntea, koska se selittää jokaisen oudon tuloksen: venytetty (stretched) kuva on CTM, jossa on yhteensopimattomia skaalaustekijöitä (mismatched scale factors), neljälläkymmenellä sivulla täysin identtinen logo on yksi form XObject, jota on kutsuttu neljäkymmentä kertaa, ja ylösalaisin renderöityvä (renders upside down) kuva johtuu merkinvaihdosta (sign flip) matriisissa, ei korruptoituneesta tiedostosta

Mihin tämä johtaa

Grafiikkamalli (graphics model) on pieni, kun näet sen muodon (shape). Sisältövirta (content stream) on muuttuvaa tilaa (mutable state) vasten ajettavaa postfix-tavukoodia (postfix bytecode); koordinaatit alkavat vasemmasta alakulmasta ja kulkevat CTM:n (current transformation matrix) läpi; polut (paths) rakennetaan hiljaisesti (silently) ja maalataan (painted) yhdellä tarkoituksellisella operaattorilla; väri- ja viiva-asetukset (line settings) pysyvät voimassa kunnes rajaat (bracket) ne komennoilla q/Q; kuvat ja uudelleenkäytettävä grafiikka ovat XObjecteja, jotka on sijoitettu muuntamalla (transforming) yksikköneliötä (unit square). Lähes jokainen hämmentävä renderöintitulos palautuu (reduces) johonkin näistä viidestä säännöstä. Jos haluat nähdä, miten nämä grafiikkaoperaattorit asettuvat (sit inside) laajempaan objektimalliin (object model), sivu-sanakirjoihin ja niihin osoittavaan ristiviitetaulukkoon (cross-reference table), se kerros on käsitelty teknisessä katsauksessa PDF-tiedoston rakenteeseen, ja yksinkertaisen PDF:n rakentaminen alusta alkaen käy tavut läpi alusta loppuun. Tekstin piirtäminen elää omassa operaattoriperheessään ja sillä on omat sudenkuoppansa (pitfalls), jotka on käsitelty sisarartikkelissa koskien PDF:n tekstin ja fonttien käsittelyä

Tässä esitetyt Delphi-piirtokutsut, MoveTo, LineTo, Stroke, Rectangle, Fill, SetRGBFillColor, AddImage ja ShowImage, ovat osa Delphille ja C++Builderille suunnattua HotPDF-komponenttia, joka tuottaa (emits) nämä sisältövirran operaattorit puolestasi