PDF atlas source code

From OpenStreetMap Wiki
Jump to navigation Jump to search

Source code for PDF atlas


#!/usr/bin/perl
use strict;
use PDF::API2;
use Data::Dumper;
use constant mm => 25.4/72;

my $ConfigFile = shift() || die("Usage: $0 [Config file]\n");
CreateAtlas($ConfigFile);
exit;


#---------------------------------------------------------
# Create a PDF road atlas, based on options in a config file
#---------------------------------------------------------
sub CreateAtlas(){
  my $PDF = PDF::API2->new();
  
  # Read the configuration file
  my $Options = ReadFile(shift());
  
  # Title page
  AddTitlePage($PDF, $Options->{"Title"});
  
  my $Data = LoadData($Options);

  MapPages($PDF, $Options, $Data);
  
  # License page
  TextPage($PDF, $Options->{"License"});
  
  # Save the PDF
  printf STDERR "Saving %s\n", $Options->{"Filename"};
  $PDF->saveas($Options->{"Filename"});
}

#---------------------------------------------------------
# Adds a title page to the PDF document
#---------------------------------------------------------
sub AddTitlePage()
{
  my ($PDF, $Title) = @_;
  my $Font = $PDF->corefont('Helvetica');
  
  # Add file meta-informationshift()
  AddMetaInfo($PDF, $Title);
   
  # Create a new page for the title
  my $Page = $PDF->page;
  
  my $TextHandler = $Page->text;
  $Page->mediabox(210/mm, 297/mm);
  $Page->cropbox (10/mm, 10/mm, 200/mm, 287/mm);
  
  # Write the page title (TODO: read this from a text file)
  foreach my $Line(
    ("105, 200, 11, centre, black, $Title",
    "105, 60, 6, centre, black, Created from OpenStreetMap data",
    "105, 50, 6, centre, black, http://openstreetmap.org.uk/",
    "105, 40, 6, centre, black, Published under a Creative Commons license",
    ))
    {
    my ($X, $Y, $Size, $Pos, $Colour, $Text) = split(/,\s+/, $Line);
    
    $TextHandler->font($Font, $Size/mm );
    $TextHandler->fillcolor($Colour);
    $TextHandler->translate($X/mm, $Y/mm);
    
    $TextHandler->text($Text) if($Pos eq "left");
    $TextHandler->text_center($Text) if($Pos eq "centre");
    $TextHandler->text_right($Text) if($Pos eq "right");
    }
}

#---------------------------------------------------------
# Adds a page of preformatted text, from a text file
#---------------------------------------------------------
sub TextPage()
{
  my ($PDF, $Filename) = @_;
  my $Page = $PDF->page;
  
  my $TextHandler = $Page->text;
  $TextHandler->font($PDF->corefont('Helvetica'), 6/mm );
  $TextHandler->fillcolor('black');
  
  open(my $fp, "<", $Filename) || die("Can't open $Filename ($!)\n");
  
  my ($x, $y) = (30, 250);
  foreach my $Line(<$fp>){
    chomp $Line;
    $TextHandler->translate($x/mm, $y/mm);
    $TextHandler->text($Line);
    $y -= 7;
  }
}
#---------------------------------------------------------
# Adds meta-information to a PDF
#---------------------------------------------------------
sub AddMetaInfo(){
  my ($PDF, $Title) = @_;
  
  # Timestamp (using perl standard functions to make script easier to install)
  my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = gmtime(time);
  my $Timestamp = sprintf("D:%04d%02d%02d%02d%02d%02d+00;00", $year+1900, $mon+1, $mday, $hour, $min, $sec);
  
  $PDF->info(
        'Author'       => "OpenStreetMap community",
        'CreationDate' => $Timestamp,
        'ModDate'      => $Timestamp,
        'Creator'      => "OJW's script",
        'Producer'     => "PDF::API2",
        'Title'        => $Title,
        'Subject'      => "Cartography",
        'Keywords'     => "OpenStreetMap"
    );
}

sub LoadData()
{
  my ($Options) = @_;
  my %Data;
  
  foreach my $Datatype("Styles","Equivalences","Filters")
  {
    my $Filename = $Options->{$Datatype};
    $Data{$Datatype} = ReadFile($Filename);
  }
 
  my ($LatC, $LongC, $Size) = split(/,/,$Options->{"Area"});
  my $Margin = 1.5;
  my %Bounds = (
    "W" => $LongC - $Margin * $Size,
    "E" => $LongC + $Margin * $Size,
    "S" => $LatC - $Margin * $Size,
    "N" => $LatC + $Margin * $Size);
  
  $Data{"Coast"} = LoadGSHHS($Options->{"Coast"}, \%Bounds) if($Options->{"Coast"});
    
  $Data{"Segments"} = LoadOSM($Options->{"Data"}, \%Bounds) if($Options->{"Data"});
  
  return(\%Data);
}

sub LoadGSHHS()
{
  my ($Filename, $Bounds) = @_;
  my @Coasts;

  # Open the file for reading, binary
  open(my $fp, "<", $Filename) || die("Can't open GSHHS file $Filename ($!)\n");
  binmode($fp);
  printf "Loading coastlines from %s\n", $Filename;
  
    printf "Bounds2 lat %f to %f, long %f to %f\n",
    $Bounds->{"S"},
    $Bounds->{"N"},
    $Bounds->{"W"},
    $Bounds->{"E"}; 

  # Continue reading headers until end of file
  while(read($fp, my $header, 8 * 4 + 2 * 2))
    {
    my ($Prev, $PLat, $PLon) = (0,0,0);
    my ($id, $numpoints, $level,
        $west, $east, $north, $south,
        $area, $crosses, $source) = unpack("NNNN4Nnn", $header);

    # Loop through vertices in this polygon
    foreach(1..$numpoints)
      {
      # Read from file
      die("GSHHS error\n") if(!read($fp, my $datapoint, 8));

      # Unpack binary data into local variables
      my($x, $y) = unpack("NN", $datapoint);

      my $Lat = coastcode($y);
      my $Lon = coastcode($x);
      $Lon -= 360 if($Lon > 180);

      if($Lat > $Bounds->{"S"} and 
        $Lat < $Bounds->{"N"} and 
        $Lon > $Bounds->{"W"} and 
        $Lon < $Bounds->{"E"}){
      
        push(@Coasts, "$PLat, $PLon, $Lat, $Lon") if($Prev);
      }
      ($PLat, $PLon, $Prev) = ($Lat, $Lon, 1);
      }
    }
  close($fp);
  
  return(\@Coasts);
}

sub coastcode(){
  my $x = shift();
  if($x > 0x80000000){
    $x -= 0xFFFFFFFF; $x--;
  }
  return($x / 1E+6);
}

sub LoadOSM()
{
  my ($Filename, $Bounds) = @_;
  
  open(my $fp, "<", $Filename) or die("Can't open $Filename ($!)\n");
  printf "Loading streetmaps from %s\n", $Filename;
  my @Lines = <$fp>;
  chomp @Lines;
  close($fp);  

  return(\@Lines);
}

sub MapPages()
{
  my ($PDF, $Options, $Data) = @_;
  
  my $Filename = $Options->{"Places"};
  open(my $fp, "<", $Filename) or die("Can't open $Filename ($!)\n");
  
  my @Maps;
  
  foreach my $Line(<$fp>){
    chomp $Line;
    if(substr($Line,0,1) ne "#")
    {
    	push(@Maps, $Line);
    }
  }
  printf "Creating %d maps\n", scalar(@Maps);
  
  my ($LatC, $LongC, $Size) = split(/,/,$Options->{"Area"});
  MapContentsPage($PDF, \@Maps, $LatC, $LongC, $Size, $Data);
  
  
  foreach my $Map(@Maps)
  { 
      my ($Name, $More) = split(/:\s*/, $Map);
      my ($Lat, $Long, $Size, $Type) = split(/,\s+/, $More);
      
      print "Generating map for $Name at $Lat, $Long\n";      
      MapPage($PDF, $Lat, $Long, $Size, $Name, $Type, $Data);
  }
}

#---------------------------------------------------------
# Adds a simple "text-style" contents page
#---------------------------------------------------------
sub ContentsPage()
{
	my ($PDF, $Maps) = @_;
  	my $Page = $PDF->page;
  	my $TextSize = 4.5;

	my $PageNum = $PDF->pages + 1; # Assume first map is on page after the current one
  	my $TextHandler = $Page->text;
  	
  	# Title
  	$TextHandler->fillcolor('black');
	$TextHandler->font($PDF->corefont('Helvetica'), 7/mm );
    $TextHandler->translate(40/mm, 232/mm);
	$TextHandler->text("Contents:");

	# Setup size and position of contents items
	$TextHandler->font($PDF->corefont('Helvetica'), $TextSize/mm );  
    my $y = 220;
        	
	foreach my $Map(@$Maps)
	{
		my ($Name, $Misc) = split(/:/, $Map);

		# Name
	    $TextHandler->translate(40/mm, $y/mm);
    	$TextHandler->text($Name);

		# Page num    	
	    $TextHandler->translate(150/mm, $y/mm);
    	$TextHandler->text($PageNum++);
    	
	    $y -= ($TextSize+1);		
	}
}
sub MapContentsPage(){
  my ($PDF, $Maps, $Lat, $Long, $Size, $Data) = @_;
  my $Proj = SetupProjection($Lat, $Long, $Size);

  my $Page = $PDF->page;
  $Page->mediabox(210/mm, 297/mm);
  
  my $gfx = $Page->gfx;
  my $text = $Page->text;
  $text->font($PDF->corefont('Helvetica'), 4/mm );
	  
  DrawCoastline($Data->{"Coast"}, $Proj, $gfx);
  
  my $Count = $PDF->pages + 1;
  foreach my $Map(@$Maps)
  {
    my ($Name, $More) = split(/:\s*/, $Map);
    my ($LatS, $LongS, $SizeS, $Type) = split(/,\s+/, $More);
    my $IsCity = $Type eq "city";
    my $Colour = $IsCity ? "#000000" : "#40C0FF";
    
    my $Proj2 = SetupProjection($LatS, $LongS, $SizeS);
    
    my ($x1, $y1) = Project($Proj, $Proj2->{"S"}, $Proj2->{"W"});
    my ($x2, $y2) = Project($Proj, $Proj2->{"N"}, $Proj2->{"E"});
    
    # Map border
    $gfx->strokecolor($Colour);
    $gfx->rect($x1/mm, $y1/mm, ($x2-$x1)/mm, ($y2-$y1)/mm); 
    $gfx->stroke();
    $gfx->endpath();
    
    # Text (inside area maps, outside city maps)
    if(!$IsCity){
      $text->fillcolor($Colour);
      $text->translate((($IsCity ? $x2 : $x1) + 1)/mm, ($y2 - 5)/mm);
      $text->text($Name);
    }
    
    $Count++;
  }
}

#---------------------------------------------------------
# Adds a page of maps
#---------------------------------------------------------
sub MapPage(){
  my ($PDF, $Lat, $Long, $Size, $Name, $Type, $Data) = @_;

  my $Proj = SetupProjection($Lat, $Long, $Size);

  my $Page = $PDF->page;
  $Page->mediabox(210/mm, 297/mm);
  
  my $gfx = $Page->gfx;
  my $Font = $PDF->corefont('Helvetica');

  # Draw the "simple" (A1 - G9) grid
  mapGrid($Page, 10, 287, 200, 10, $Font, $Proj);

  DrawCoastline($Data->{"Coast"}, $Proj, $gfx);

  DrawOSM($Data->{"Segments"}, $Proj, $gfx, $Data->{"Styles"});

  ScaleBar($PDF, $Page, $Proj);
  
  # White-out the edges of the page (stop maps spilling over)
  my $edges = $Page->gfx;
  $edges->rect(0/mm, 0/mm, 10/mm, 297/mm); # left
  $edges->rect(0/mm, 0/mm, 210/mm, 10/mm); # bottom
  $edges->rect(200/mm, 0/mm, 210/mm, 297/mm); # right
  $edges->rect(0/mm, 287/mm, 210/mm, 297/mm); # top
  $edges->fillcolor("#FFFFFF"); 
  $edges->fill; 
  $edges->endpath;
  
  # Map border
  $edges->strokecolor("#000080");
  $edges->rect(10/mm, 10/mm, 190/mm, 277/mm); 
  $edges->stroke();
  $edges->endpath();
  
  # Page number
  my $text = $Page->text;  
  $text->fillcolor("#000000"); 
  $text->font($Font, 6/mm );
  $text->translate( 10/mm, 289/mm );
  $text->text(sprintf("%d", $PDF->pages));
  
  # Page name
  $text->translate( 10/mm, 3/mm );
  $text->text($Name);
  
  # URL
  $text->translate( 200/mm, 3/mm );
  $text->text_right("http://openstreetmap.org/");
}

#---------------------------------------------------------
# Draw coastline segments	
#---------------------------------------------------------
sub DrawCoastline()
{
  my($Coast, $Proj, $gfx) = @_;

  if($Coast)
  {
    foreach my $Line(@$Coast)
    {
      my ($Lat1, $Long1, $Lat2, $Long2) = split(/,/, $Line);
      if(PossiblyOn($Proj, $Lat1, $Long1, $Lat2, $Long2))
      {
        my ($x1, $y1) = Project($Proj, $Lat1, $Long1);
        my ($x2, $y2) = Project($Proj, $Lat2, $Long2);
          
        my $Colour = "#0000FF";
        
        $gfx->strokecolor($Colour);
        $gfx->move($x1/mm,$y1/mm);
        $gfx->line($x2/mm,$y2/mm); 
        $gfx->stroke();
        $gfx->endpath();
      }
    }
  }	
}

#---------------------------------------------------------
# Draw OpenStreetMap segments
#---------------------------------------------------------
sub DrawOSM()
{
  my($Segments, $Proj, $gfx, $Styles) = @_;
  
  if($Segments)
  {
    foreach my $Line(@$Segments)
    {
      my($Lat1,$Long1,$Lat2,$Long2,$Class,$Name,$Highway) = split(/,/,$Line);
      
      $Class = $Highway if(!$Class);
      
      $Class = lc($Class);  
      if(PossiblyOn($Proj, $Lat1, $Long1, $Lat2, $Long2))
      {
        my ($x1, $y1) = Project($Proj, $Lat1, $Long1);
        my ($x2, $y2) = Project($Proj, $Lat2, $Long2);
  
        my $Colour = exists($Styles->{$Class}) ? $Styles->{$Class}: "#A0A0A0";
  
        $gfx->strokecolor($Colour);
        $gfx->move($x1/mm,$y1/mm);
        $gfx->line($x2/mm,$y2/mm); 
        $gfx->stroke();
        $gfx->endpath();
      }
    }
  }
}
#---------------------------------------------------------
# Draw a basic (uncorrelated with anything) grid on a map
#---------------------------------------------------------
sub mapGrid()
{
  my ($Page, $x1, $y1, $x2, $y2, $Font, $Proj) = @_;
  my $grid = $Page->gfx;
  
  my $xLabels = "ABCDEFG";
  my $dx = ($x2 - $x1) / 7;
  my $dy = ($y2 - $y1) / 10;
  
  my $gridtext = $Page->text;  
  $gridtext->fillcolor("#C1E4FF"); 
  $gridtext->font($Font, 3/mm );
  
  my $count = 0;
  for(my $x = $x1; $x < $x2-1; $x += $dx)
  {
    $grid->move($x/mm,$y1/mm);
    $grid->line($x/mm,$y2/mm); 
    
    $gridtext->translate(($x + $dx - 4)/mm, ($y1 - 4)/mm );
    $gridtext->text(substr($xLabels, $count++, 1));
  }
  
  $count = 1;
  for(my $y = $y1; $y > $y2+1; $y += $dy)
  {
    $grid->move($x1/mm,$y/mm);
    $grid->line($x2/mm,$y/mm); 
    
    $gridtext->translate(($x1 + 2)/mm, ($y + $dy + 2)/mm );
    $gridtext->text($count++);
    
  }
  
  $grid->strokecolor("#C1E4FF");
  $grid->stroke();
  $grid->endpath();
}

#---------------------------------------------------------
# Draw a scalebar on a map
#---------------------------------------------------------
sub ScaleBar()
{
  my ($PDF, $Page, $Proj) = @_;
  my $EarthRadius = 6378;
  
  my $ScaleX = 20;
  my $ScaleY = 20;
  my $dx = 100;
  
  my $ScaleLenDeg = $Proj->{"dLong"} * $dx / $Proj->{"dx"};
  my $ScaleLenGuess = $EarthRadius * DegToRad($ScaleLenDeg) * $Proj->{"latlong_ratio"};
  
  my $ScaleLen = Round($ScaleLenGuess);
  $dx *= $ScaleLen / $ScaleLenGuess;
  
  my $scale = $Page->gfx;
  
  # Bordered rectangle, to get rid of any bits of map under the scalebar
  BorderRect($scale,
    ($ScaleX - 2)/mm, 
    ($ScaleY - 8)/mm, 
    ($dx + 4)/mm, 
    10/mm,
    "#FFFFFF", 
    "#CCCCFF");
  
  # Scalebar length
  $scale->move($ScaleX/mm, $ScaleY/mm);
  $scale->line(($ScaleX + $dx)/mm, $ScaleY/mm); 
  # and tickmarks
  foreach(0..10){
    my $Len = (($_ % 5) == 0) ? 2 : 1;
    my $X = $ScaleX + $dx * $_ / 10;
    $scale->move($X/mm, $ScaleY/mm);
    $scale->line($X/mm, ($ScaleY - $Len)/mm);
  }
  
  $scale->strokecolor("#000000");
  $scale->stroke();
  
  # Scalebar text
  my $text = $Page->text;  
  $text->fillcolor("#000000"); 
  $text->font($PDF->corefont('Helvetica'), 4/mm );
  $text->translate($ScaleX/mm, ($ScaleY - 6)/mm );
  $text->text(FormatNum($ScaleLen) . " km");
    
}

#---------------------------------------------------------
# Draw a rectangle with fill and border
#---------------------------------------------------------
sub BorderRect()
{
  my ($gfx, $x, $y, $w, $h, $fill, $line) = @_;
  $gfx->rect($x, $y, $w, $h);
  $gfx->fillcolor($fill); 
  $gfx->fill; 
  
  $gfx->rect($x, $y, $w, $h);
  $gfx->strokecolor($line);
  $gfx->stroke();
}

#---------------------------------------------------------
# Format a number without trailing zeros
#---------------------------------------------------------
sub FormatNum()
{
  my $Text = sprintf("%lf", shift());
  $Text =~ s/(\.\d*?)0+$/\1/;
  $Text =~ s/\.$//;
  return($Text);
}

#---------------------------------------------------------
# Utility: round a number to the nearest power of 10
#---------------------------------------------------------
sub Round()
{
  my $x = shift();
  my $Result = $x;
  foreach(0.1, 0.5, 1, 2, 5, 10, 50, 100){
    $Result = $_ if($x > $_);
    }
  return($Result);
}

#---------------------------------------------------------
# Tests whether a line can possibly intersect an area
#---------------------------------------------------------
sub PossiblyOn()
{
  my ($Proj, $Lat1, $Long1, $Lat2, $Long2) = @_;
  return(0) if($Long1 < $Proj->{"W"} && $Long2 < $Proj->{"W"});
  return(0) if($Long1 > $Proj->{"E"} && $Long2 > $Proj->{"E"});
  return(0) if($Lat1 < $Proj->{"S"} && $Lat2 < $Proj->{"S"});
  return(0) if($Lat1 > $Proj->{"N"} && $Lat2 > $Proj->{"N"});
  return(1);
}

#---------------------------------------------------------
# Project a lat/long onto x,y coordinates
#---------------------------------------------------------
sub Project()
{
  my ($Proj, $Lat, $Long) = @_;

  my $x = $Proj->{"x1"} + $Proj->{"dx"} * ($Long - $Proj->{"W"}) / $Proj->{"dLong"};
  my $y = $Proj->{"y1"} + $Proj->{"dy"} * ($Lat - $Proj->{"S"}) / $Proj->{"dLat"};
  
  return($x,$y);
}

#---------------------------------------------------------
# Initialise a map projection for later use by Project()
#---------------------------------------------------------
sub SetupProjection()
{
  my ($Lat, $Long, $Size) = @_;
  my %Proj;
  
  # Scale to A4 page (note: A4 page sizes are hardcoded throughout at the moment)
  $Proj{"x1"} = 10;
  $Proj{"x2"} = 200;
  $Proj{"y1"} = 10;
  $Proj{"y2"} = 287;

  $Proj{"dx"} = $Proj{"x2"} - $Proj{"x1"};
  $Proj{"dy"} = $Proj{"y2"} - $Proj{"y1"};

  $Proj{"page_ratio"} = $Proj{"dy"} / $Proj{"dx"};

  # Size is used to define N-S limits
  $Proj{"N"} = $Lat + $Size;
  $Proj{"S"} = $Lat - $Size;
  $Proj{"dLat"} = $Proj{"N"} - $Proj{"S"};
  
  # Long/lat ratio is cos(lat) (very simple "projection")
  $Proj{"latlong_ratio"} = cos(DegToRad($Lat)); # ~0.5 for northern europe = long/lat
  	  
  $Proj{"dLong"} = $Proj{"dLat"} * $Proj{"page_ratio"} * $Proj{"latlong_ratio"};
  $Proj{"W"} = $Long - 0.5 * $Proj{"dLong"};
  $Proj{"E"} = $Long + 0.5 * $Proj{"dLong"};
  
  return(\%Proj);
}
sub DegToRad(){
 return(3.1415926 * shift() / 180);
}
#---------------------------------------------------------
# Reads a file consisting of "Name: Value" pairs, and 
# returns them as a hash
#---------------------------------------------------------
sub ReadFile(){
  my $Filename = shift();
  my %Options;
  open(my $fp, $Filename) || die("Can't open $Filename ($!)\n");
  printf STDERR "  - Reading %s\n", $Filename;
  
  foreach my $Line(<$fp>){
    chomp $Line;
    if(substr($Line,0,1) ne "#"){
      my ($Key, $Value) = split(/:\s*/, $Line);
      $Options{$Key} = $Value;
    }
  }
  close $fp;
  return(\%Options);
}