PDF's genereren voor een wandelroute

PDF's genereren voor een wandelroute

Deze post gaat over het automatisch genereren van PDF bestanden, een wat technischer onderwerp. In dit geval met achtergrondinformatie bij wandel- en fietsroutes. We hebben afgelopen maanden namelijk gewerkt aan een project met Landschap Overijssel. Daarbij hebben we een eenvoudig te gebruiken backend-systeem gemaakt waar vrijwilligers wandel- en fietsrouters kunnen intekenen op een kaart. Langs de route kunnen ze "interessante locaties" aangeven, en daar achtergrondinformatie bij schrijven, en foto's (en video's en geluidsfragmenten) bij plaatsen. Denk daarbij aan historisch interessante locaties (oude veenwingebieden, gebeurtenissen tijdens de oorlog), of mooie natuur met bijzondere planten of dieren. Die routes kunnen vervolgens gepubliceerd worden. Ze komen dan automatisch op de website van Landschap Overijssel terecht, en ook in de mobiele apps voor iOS en voor Android.

Maar een ouderwetse papieren versie van de achtergrondinformatie is ook nog steeds zeer gewild. En terecht. Wat is er nou makkelijker dan een paar A4'tjes in je binnenzak meenemen? Ze zijn makkelijk met mensen te delen, en in tegenstelling tot bij een telefoon loop je geen gevaar dat je A4'tjes met een lege accu komen te zitten.

Goed, we willen dus een PDF van een route die door een vrijwilliger is gemaakt. Hoe doe je dat automatisch? Ik heb de opties bekeken, en heb net een tweede methode geïmplementeerd. In dit artikel beschrijf ik de opties met voor- en nadelen, en de ervaringen die we daarmee hebben gehad. Om het meteen even concreet te maken hier meteen een screenshot van een pagina van een PDF zoals die er op het moment van schrijven uit ziet:

Screenshot van een pagina uit een automatisch gegenereerde PDF
Screenshot van een pagina uit een automatisch gegenereerde PDF

Waarom eigenlijk een PDF?

Eerst nog even een stapje terug. Waarom hebben we eigenlijk een losse PDF? Alle informatie staat ook gewoon op de website. Kun je die website niet gewoon printen? Nou, daar zijn een aantal redenen voor.

De webpagina is geoptimaliseerd voor gebruik in een browser. Daar zitten dynamische elementen in, zoals animaties. En standaard zijn niet alle details direct zichtbaar. Bijvoorbeeld, door een klik op een samenvatting krijg je pas de volledig teksten/afbeeldingen te zien. Nu is het technisch wel mogelijk om de pagina anders te "stylen" als hij wordt afgedrukt. Maar de bewegingsruimte is dan erg beperkt.

Je zou alsnog per wandelroute een webpagina kunnen maken die altijd exclusief gestyled is voor afdrukken. Dus een pagina die je bij normaal gebruik niet aan de gebruiker toont. Je blijft echter met beperkingen van browsers zitten: Met CSS heb je geen volledige controle over hoe je pagina gelayout wordt bij het printen. Ook voegt een browser vaak een lelijke header & footer aan elke geprinte pagina toe. Plus, de layout zal verschillen per browser. De oplossing? Gewoon een PDF!

Hoe werkt een PDF?

PDF is het standaard formaat voor printbare bestanden. Van elke pagina in een PDF is precies vastgelegd welke tekst/afbeelding op welke plek staat. Elk PDF-programma zal een PDF dus op dezelfde manier tonen en printen. Het nadeel hieraan is dat je (als maker van de PDF) al het layouten wel zelf moet doen. Bij een browser vertel je in grote lijn welke plaatjes/teksten je wilt hebben. Je geeft ruwe richtinglijnen aan voor de layout. Dat is flexibel, en dat is ook noodzakelijk. Niet iedereen heeft namelijk een even groot computerscherm. Maar de browser doet vervolgens het "moeilijke" werk om alles op een mooie plek op het scherm te krijgen.

Bij het maken van PDF'jes moet je dat "moeilijke" werk dus zelf doen. Gelukkig heb je voor je PDF veel minder vrijheidsgraden. Het formaat van een pagina is altijd een A4. En je hebt meestal een beperkt datamodel, dus je hoeft niet heel veel verschillende soorten layout te kunnen tekenen. Een browser moet dat wel, die is dan ook veel complexer. Als je het goed doet kun je met relatief weinig code toch je PDF's mooi layouten.

Hoe maak ik een PDF?

Welke opties zijn er om een PDF te maken?

  • Optie 1: Gebruik losse softwaretools die PDF's kunnen genereren, gegeven een gestructureerde input.
  • Optie 2: Gebruik aan de server-kant een browser die een webpagina kan laden en vervolgens als PDF weg kan schrijven. Je gebruikt dan alsnog HTML/CSS om de layout te maken. Maar omdat de browser op je eigen server draait heb je altijd dezelfde browser en dus hetzelfde resultaat.
  • Optie 3: Bouw zelf een PDF-bestand op. Je berekent dan zelf de layout, en tekent zelf de teksten/afbeeldingen op elke pagina van de PDF. Bij het opbouwen van de PDF moet de programmeur dus zelf rekening houden met hoeveel tekst/afbeelding er nog op je pagina past, en waar dat terecht moet komen.

Optie 1: Externe tools gebruiken

Een voor de hand liggende optie. Gebruik gewoon software die al door anderen gemaakt is. Bijvoorbeeld LaTeX (op basis van TeX). LaTeX wordt vooral in de academische wereld veel gebruikt om wetenschappelijke artikelen op te maken. Je beschrijft de inhoud van je document met commando's. Bijvoorbeeld voor "titel", "paragraaf", en "afbeelding". LaTeX leest die commando's in en berekent een mooie layout en schrijft dat weg naar een PDF. Tex en LaTeX staan er om bekend dat het heel goed kan layouten. Het spreidt tekst altijd op een soepele manier over je pagina uit. Het zorgt er bijvoorbeeld voor dat de laatste regel van een alinea niet in z'n eentje bovenaan een pagina komt te staan. Of een regel niet bestaat uit slechts het laatste woord van een alinea.

Maar met LaTeX heb je niet volledige controle over de indeling van blokken tekst en afbeeldingen op de pagina. En als je speciale eisen hebt (zoals een stippellijn die naast je tekst loopt en verband houd met die tekst), dan wordt dat ook weer een stukje lastiger om voor elkaar te krijgen. Daarbij komt dat LaTeX een flinke infrastructuur (dependencies) nodig heeft om te functioneren. En dan hebben we het nog niet eens over de learning curve!

Maar er zijn meer externe tools. Er is een standaard genaamd XSL Formatting Objects. XSL-FO is gerelateerd aan HTML/CSS, maar ook specifiek bedoeld om te renderen naar "papier". Echter, een snelle analyse van de XSL-FO-implementerende tools geeft niet het gevoel dat die geschikt zijn om teksten volledige automatisch te layouten. Wederom ook zeker niet als je extra/bijzondere eisen hebt qua layouten. Je blijft gewoon een extra stap te ver van je PDF afzitten. Tussen de tool en de PDF gebeurt van alles waar je geen controle over hebt. Plus, je moet zo'n tool ook weer begrijpen. Meestal hebben die ook weer een enorme berg documentatie, beperkingen en bugs waar je mee om moet gaan. Dat XSL-FO niet meer actief ontwikkelt wordt als standaard geeft ook geen gevoel van toekomstbestendigheid.

Optie 2: Browser op de server

Een andere "makkelijke" oplossing is het gebruiken van een browser aan de server kant. Die browser heeft geen GUI, maar is "headless" zoals we dat noemen, en werkt dus alleen op de achtergrond. Hij kan wel gewoon een webpagina renderen, en die wegschrijven als een PDF. We moeten dan dus wel een webpagina maken die op mooie manier op A4'tjes getekend kan worden. En daar zit meteen de lastigheid. HTML/CSS hebben wel mogelijkheden om specifiek prints te stylen. Maar dat soort features zijn over het algemeen geen topprioriteit voor browsermakers. Browsers zijn nu, zonder dat soort features, al complex genoeg. Dat komt o.a. omdat de webstandaarden voortdurend in beweging zijn, de eisen voortdurend hoger worden, en er nog hard geconcurreerd wordt op snelheid tussen de browsermakers. Kortom, verwacht niet dat je met een headless browser dezelfde kwaliteit van renderen gaat krijgen als met een normale browser.

Maar welke optie hebben we qua headless browsers? PhantomJS is een voor de handliggende keuze. Er zijn ook andere opties, maar meestal is het een flinke klus om die software geïnstalleerd (gecompileerd) te krijgen op een Linux server. PhantomJS is relatief makkelijk te installeren via npm, de node package manager. PhantomJS is gebaseerd op WebKit, zoals tegenwoordig bijna alle browsers, als ze tenminste niet van Microsoft (Internet Explorer, Edge) en Mozilla (Firefox) komen. PhantomJS heeft dus niet het wiel opnieuw uitgevonden, ze hebben de code vooral toegepast om "headless" te kunnen draaien. PhantomJS is in gebruik gelukkig eenvoudig. Je geeft het een URL, die rendert het, en de PDF wordt netjes weggeschreven. Dat ziet er ongeveer als volgt uit (waarbij rasterize.js een simpel JavaScriptje is dat enkele PhantomJS functies aanroept, om een pagina te laden, en na een timeout weg te schrijven als PDF, zie de website van PhantomJS):

phantomjs rasterize.js http://example.org/pdfview.html /tmp/output.pdf A4

De eerste implementatie van onze PDF's voor routes hebben we dan ook op basis van PhantomJS gebouwd. Dat leek heel aardig te voldoen. Helaas zagen we al snel problemen:

  • Tijdens het ontwikkelen op de Mac zagen de PDF's er best mooi uit. Maar bij renderen op een Linux server waren de PDF's ineens een stuk minder mooi. PhantomJS renderde dan nogal lelijke letterspacing. Mogelijk kwam dat doordat niet de juiste fonts of Xorg support libraries waren geinstalleerd. Echter, ontdekken wat je dan wel nodig had om mooie PDF's te krijgen was een flinke klus, dat is dan ook niet verder nagejaagd na een eerste poging.
  • Er zitten bugs in PhantomJS. Daardoor kon er (op ogenschijnlijk willekeurige pagina's) tekst over een "floatende" afbeelding heen lopen. Die tekst zou netjes om de afbeelding heen moeten lopen. Aangezien je een tool gebruikt die het layouten voor je doet, heb je zelf geen controle over dat soort positioneringen.
  • Niet heel voor de hand liggend. Maar je kunt problemen krijgen met timing. Je vraagt PhantomJS namelijk om een pagina te renderen. Als je JavaScript gebruikt om een deel van de pagina te renderen (wat wij doen), dan gebeurt dat "asynchroon". Het lastige is dat je vanuit PhantomJS niet weet wanneer je pagina klaar is met renderen. En dus wanneer je het resultaat weg moet schrijven naar een PDF. Je moet wel lang genoeg wachten om alles gerenderd te krijgen. Maar gebruikers wachten via hun browser op je PDF, dus je wil ook niet te lang wachten...

Ter illustratie een pagina gerenderd door PhantomJS. Je kunt zien dat de tekst er net niet mooi uit ziet.

Screenshot van een pagina uit een PDF gegenereerd met PhantomJS
Screenshot van een pagina uit een PDF gegenereerd met PhantomJS

Omdat de tekst niet zo fraai getekend werd, en gezien de bug waardoor tekst over afbeeldingen werd getekend, zijn we overgegaan op het zelf samenstellen van de PDF's.

Optie 3: Zelf de PDF tekenen

Als je PDF's zelf tekent heb je gelukkig volledige controle! Het nadeel is dat je ook meer werk moet doen. Je moet uiteindelijk zelf een PDF in correct formaat zien te genereren. Dat is gelukkig goed te doen met bestaande libraries. Ik heb de "reportlab toolkit" gebruikt. Dat is een python library met uitstekende documentatie: Een gebruikershandleiding van een paar honderd pagina's, met goede voorbeelden. Ze blijven dichtbij de concepten van PDF's, dat helpt in het begrip van het gebruik van de library (als je een beetje weet hoe PDF's werken). Goed, het genereren van een PDF is dus te doen. Maar je moet nog wel de layout bepalen. En "low-level" instructies genereren om op de juiste plaatsen tekst en afbeeldingen te tekenen.

Hoe werkt het samenstellen van een PDF? Je krijgt een canvas waar je op moet tekenen. Dat heeft de grootte van een A4'tje. Dus 29,7cm hoog en 21,0cm breed. Maar in PDF's teken je in eenheden genaamd "points". Zo werkt de reportlab toolkit ook. Om het toch een beetje begrijpelijk te houden tijdens het programmeren gebruik ik functies die gewoon op basis van millimeters kunnen tekenen. Dan kun je tenminste ook makkelijk vanaf een voorbeeld-printje met een lineaal meten wat de witruimtes moeten zijn. Je moet wel opletten dat je altijd goed converteert tussen points en millimeters. Als je dat een keer vergeet loopt je layout volledig de soep in. Ook moet je nog converteren tussen onder en boven. De verticale "y"-nulpunt staat in PDF's namelijk onderaan de pagina. Dus coordinaat (0,0) staat linksonderaan je pagina. Tijdens het tekenen wil je gewoon denken vanaf linksboven. Ter illustratie een stukje python code dat "Hello world!" op een A4'tje zet:

import reportlab.pdfgen.canvas import reportlab.lib.pagesizes out = open('/tmp/output.pdf', 'wb') canvas = reportlab.pdfgen.canvas.Canvas(out, pagesize=reportlab.lib.pagesizes.A4) width, height = reportlab.lib.pagesizes.A4 x = width/2 y = height/2 canvas.drawCentredString(x, y, "Hello world!") canvas.save()

Maar goed, hoe ga je te werk? In principe vul je de pagina van boven naar beneden, en van links naar rechts. Een alinea tekst teken je door de tekst op te delen in regels, en telkens de regels onder elkaar te tekenen. Je houdt na het tekenen van een regel de nieuwe (vertical) "y"-locatie bij. Een afbeelding teken je ook in 1 keer, en je schuift je "y"-locatie weer naar beneden met de hoogte van de afbeelding. Maar op een gegeven moment zit je onder aan je pagina. Dan past een extra regel of afbeelding niet meer. Dan begin je natuurlijk gewoon een nieuwe pagina, en ga teken je de regel of de afbeelding daar! Zo kun je een eind komen. Maar als je je layout-algoritme niet slimmer maakt zul je zien dat de teksten toch raar gelayout zullen worden. Bijvoorbeeld een losse regel bovenaan een nieuwe pagina, als het het eind van een alinea is. Als zoiets dreigt te gebeuren wil je eigenlijk de hele alinea op de nieuwe pagina tekenen. Of de alinea splitsen. Dus gewoon 1 of 2 regels van de vorige pagina naar de nieuwe pagina verplaatsen. Net zoiets geldt voor kopjes. Je wilt een kopje tekst niet los laten bestaan van de content die daar direct op volgt. Je wilt een pagina niet eindigen met een los kopje. Dat kopje wil je dan ook verplaatsen naar de volgende pagina.

Maar wij moeten nog net iets slimmere layouts opbouwen. Afbeeldingen zijn namelijk vaak niet de volledige breedte van een A4'tje. Vooral bij staande afbeeldingen zou er dan geen ruimte meer over zijn voor tekst. Alleen bij brede liggende foto's leggen we de foto uit in de hele breedte (mits de resolutie hoog genoeg is). In andere gevallen maken we de foto net niet de helft van de breedte van een pagina, en beginnen we op de andere helft tekst te tekenen. Dat maakt de pagina visueel aantrekkelijker, maar ook lastiger om te layouten. Al je andere "regels" voor het layouten gelden nog, maar soms moet je je tekst ineens naast een afbeelding tekenen. Op een gegeven moment ben je echter weer voorbij die afbeelding, en moet je weer op volledige paginabreedte gaan tekenen. Ook moet je erop letten dat je tekst netjes uitgelijnd wordt met de bovenkant van de afbeelding. Doe je dat niet, dan krijg je een ongedefinieerd gevoel dat er iets niet klopt op je pagina...

Als extra layout-eis hebben wij nog dat de content van een interessante locatie aan de linkerkant een stippellijn moet hebben. Die moet ook doorlopen als de content op de volgende pagina verder gaat. In een generieke tool is zoiets lastiger om te bouwen. In onze code, specifiek voor onze layout, is dat prima te bouwen: Je hebt alle verticale "y"-locaties toch al zelf expliciet bijgehouden.

Samenvattend werkt onze layouter ongeveer als volgt: We bouwen telkens een "block" aan content op. Als die gevuld is, tekenen we die op de canvas en beginnen we aan een volgend block. Een block kan eventueel in z'n geheel verplaatst worden naar een nieuwe pagina. Bijvoorbeeld als een afbeelding niet meer past, of als we er toch nog 1 regel tekst in hadden willen tekenen om te voorkomen dat die regel anders los in een volgend block zou komen. Een block kan (intern) worden opgedeeld in een linker en rechter helft. Waarbij (afwisselend) links of rechts de afbeelding staat, en in de andere helft tekst.

Uiteindelijk heb je niet heel veel regels code nodig om te layouten. Zulke code is over het algemeen wel heel gevoelig voor wijzigingen. Je moet zorgen dat je een goed conceptueel model hebt. Je bent namelijk al snel geneigd ergens een "kleine tweak" toe te passen. Maar meestal heeft zo'n "losstaande wijziging" toch meer invloed op je layout dan je dacht, en krijg je daarmee op een andere plek ongewenst een verkeerde layout.

Conclusie

Wil je PDF's genereren? Dan kun je snel klaar zijn door PhantomJS te gebruiken. Je moet wel HTML/CSS kunnen. En je moet hopen dat je niet tegen bugs aanloopt die je niet kunt oplossen.

Wil je meer controle over je layout? Bouw de PDF dan zelf op met een library als de reportlab toolkit. Een andere bekende library is FPDF (oorspronkelijk voor PHP, maar FPDF is naar veel andere talen geport). Je kunt je content zelf opdelen in pagina's, en daarbinnen precies layouten zoals je wilt. Het wordt er nog een stuk sneller van ook, want je hebt geen complete (PhantomJS) browser meer nodig en je hoeft niet te wachten tot die browser mogelijk klaar is met renderen. Ook heb je meer controle over de grootte en kwaliteit van de resulterende PDF bestanden, o.a. door de resolutie van de afbeeldingen die je in je PDF verwerkt.