blob: 485b8f01d047b9af01c3cf94c206a372a819f142 [file]
package Mail::SpamAssassin::PDF::Core;
use strict;
use warnings FATAL => 'all';
use Carp;
=head1 NAME
Mail::SpamAssassin::PDF::Core - Core PDF parsing functions
=head1 DESCRIPTION
This module contains the core PDF parsing functions. It is not intended to be
used directly, but rather to be used by other modules in this distribution.
=head1 METHODS
=over
=cut
use constant CHAR_SPACE => 0;
use constant CHAR_DELIM1 => 1;
use constant CHAR_DELIM2 => 2;
use constant CHAR_REGULAR => 3;
use constant TYPE_NUM => 0;
use constant TYPE_OP => 1;
use constant TYPE_STRING => 2;
use constant TYPE_NAME => 3;
use constant TYPE_REF => 4;
use constant TYPE_ARRAY => 5;
use constant TYPE_DICT => 6;
use constant TYPE_STREAM => 7;
use constant TYPE_COMMENT => 8;
use constant TYPE_BOOL => 9;
use constant TYPE_NULL => 10;
my %specials = (
'n' => "\n",
'r' => "\r",
't' => "\t",
'b' => "\b",
'f' => "\f",
);
my %class_map;
$class_map{chr($_)} = CHAR_REGULAR for 0x21..0xFF;
$class_map{$_} = CHAR_SPACE for split //, " \n\r\t\f\x{00}";
$class_map{$_} = CHAR_DELIM1 for split //, '[]()%/';
$class_map{$_} = CHAR_DELIM2 for split //, '<>';
=item new($fh)
Creates a new instance of the object. $fh is an open file handle to the PDF file or a reference to a scalar containing
the contents of the PDF file.
=cut
sub new {
my $class = shift;
my $self = bless {},$class;
$self->_init(@_);
# Look for PDF header
#
# According to the standard, this should be the first 5 bytes of the file, but some PDFs have extraneous data
# at the beginning. Acrobat Reader seems to be able to handle this, so we will too.
my $fh = $self->{fh};
{ local $/ = "%PDF-"; readline($fh); }
croak("PDF header not found") if eof($fh);
$self->{starting_offset} = tell($fh) - 5;
$self->{version} = $self->get_number();
croak("Invalid version number") unless defined($self->{version});
return $self;
}
=item clone($fh)
Returns a new instance of the object with the same state as the original, but
using the new file handle. This is useful for parsing objects within objects.
=cut
sub clone {
my $self = shift;
my $copy = bless { %$self }, ref $self;
$copy->_init(@_);
# Disable encryption for cloned objects. The parent object is already decrypted.
undef $copy->{crypt};
return $copy;
}
=item pos($offset)
Sets the file pointer to the specified offset. If no offset is specified, returns the current offset.
=cut
sub pos {
my ($self,$offset) = @_;
defined($offset)
? seek($self->{fh},$offset+$self->{starting_offset},0)
: tell($self->{fh}) - $self->{starting_offset};
}
=item get_number
Reads a number from the file. A number can be an integer or a real number. Returns undef if no number is found.
=cut
sub get_number {
my ($self) = @_;
my $fh = $self->{fh};
my $offset = $self->pos();
my $num = $self->get_token();
return unless defined($num);
if ( $num !~ /^[0-9+.-]+$/ ) {
# not a number
$self->pos($offset);
return;
}
$num += 0;
return wantarray ? ($num,TYPE_NUM) : $num;
}
=item assert_number($num)
Get the next token from the file and croak if it isn't a number. If $num is specified, croak if the number doesn't
match $num.
=cut
sub assert_number {
my ($self,$num) = @_;
my $fh = $self->{fh};
my $offset = $self->pos();
my $token = $self->get_token();
if (!defined($token) ) {
# EOF
croak "Expected number, got EOF";
}
if ($token !~ /^[0-9+.-]+$/ ) {
# not a number
$self->pos($offset);
croak "Expected number, got '$token' at offset $offset";
}
$token += 0;
if ( defined($num) && $token != $num ) {
# not the expected number
$self->pos($offset);
croak "Expected number '$num', got '$token' at offset $offset";
}
}
=item assert_token($literal)
Get the next token from the file and croak if it doesn't match the specified literal.
=cut
sub assert_token {
my ($self,$literal) = @_;
my $fh = $self->{fh};
my $offset = $self->pos();
my $token = $self->get_token();
if (!defined($token) ) {
croak "Expected '$literal', got EOF";
}
if ($token ne $literal) {
$self->pos($offset);
croak "Expected '$literal', got '$token' at offset $offset";
}
1;
}
=item get_token
Get the next token from the file as a string of characters. Will skip leading spaces and comments. Returns undef if
there are no more tokens. Will croak if an invalid character is encountered or if the token is too long.
=cut
sub get_token {
my ($self) = @_;
my $fh = $self->{fh};
# Max token length. This is to prevent reading the entire file into memory if the file is corrupt or if the
# file pointer is not set correctly.
my $limit = 256;
my $token;
while (defined(my $ch = getc($fh))) {
my $class = $class_map{$ch};
unless (defined($class)) {
seek($fh, -1, 1);
croak "Invalid character '$ch' at offset " . tell($fh);
}
if ( $class == CHAR_SPACE ) {
if ( defined($token) ) {
last;
} else {
# skip leading whitespace
next;
}
}
if ( $class == CHAR_DELIM1 ) {
if ( defined($token) ) {
seek($fh, -1, 1);
last;
} else {
return $ch;
}
}
if ( $class == CHAR_DELIM2 ) {
if (defined($token)) {
seek($fh, -1, 1);
last;
} else {
my $ch2 = getc($fh);
if (defined($ch2) && $ch2 eq $ch) {
return $ch . $ch2;
} else {
seek($fh, -1, 1);
return $ch;
}
}
}
$token .= $ch;
die "Invalid token length at offset ".tell($fh) if $limit-- == 0;
}
return $token;
}
=item get_primitive
Reads a primitive object from the file. A primitive object can be a number, string, name, array, dictionary,
or reference.
=cut
sub get_primitive {
my ($self) = @_;
my $fh = $self->{fh};
NEXT_TOKEN:
my $token = $self->get_token();
return unless defined($token);
if ( $token eq '/' ) {
return $self->_get_name();
}
if ( $token eq '<' ) {
return $self->_get_hex_string();
}
if ( $token eq '(' ) {
return $self->_get_string();
}
if ( $token eq '[' ) {
return $self->_get_array();
}
if ( $token eq '<<' ) {
return $self->_get_dict();
}
if ( $token eq '%' ) {
# skip comments
$self->get_line();
goto NEXT_TOKEN;
}
if ( $token =~ /^[0-9]+$/ ) {
my $offset = $self->pos();
my $t2 = $self->get_token();
if ( defined($t2) && $t2 =~ /^[0-9]+$/ ) {
my $t3 = $self->get_token();
if ( defined($t3) && $t3 eq 'R') {
$token = $token . ' ' . $t2 . ' ' . $t3;
return wantarray ? ($token,TYPE_REF) : $token;
}
}
$self->pos($offset);
return wantarray ? ($token,TYPE_NUM) : $token;
}
if ( $token =~ /^[0-9.+-]+$/ ) {
return wantarray ? ($token,TYPE_NUM) : $token;
}
if ( $token =~ /^true|false$/ ) {
return wantarray ? ($token,TYPE_BOOL) : $token;
}
if ( $token =~ /^null$/ ) {
return wantarray ? ($token,TYPE_NULL) : $token;
}
return wantarray ? ($token,TYPE_OP) : $token;
}
=item get_line
Reads a line from the file. A line is a sequence of characters terminated by a line feed, a carriage return, or
a carriage return/line feed combo. The returned string will include the newline character(s). The file pointer is left
at the first character after the line.
=cut
sub get_line {
my ($self) = @_;
my $fh = $self->{fh};
my $line;
my $limit = 1024;
while (defined(my $ch = getc($fh)) && $limit--) {
$line .= $ch;
if ($ch eq "\n") {
last;
} elsif ($ch eq "\r") {
my $ch2 = getc($fh);
if (defined($ch2) && $ch2 eq "\n") {
$line .= $ch2;
last;
} else {
seek($fh, -1, 1);
return $line;
}
}
}
return $line;
}
sub get_version {
my ($self) = @_;
return $self->{version};
}
=item get_startxref
Reads the startxref value from the end of the file. Will croak if the startxref value is not found or is invalid.
=cut
sub get_startxref {
my ($self) = @_;
my $fh = $self->{fh};
# read backwards from the end of the file looking for 'startxref'
my $tok = '';
my $pos = -1;
my $limit = 65536;
while ($limit--) {
seek($fh,$pos--,2);
my $ch = getc($fh);
last unless defined($ch);
if ( $ch =~ /\s/ ) {
if ( $tok eq 'startxref' ) {
seek($fh, 9, 1);
last;
}
$tok = '';
next;
}
$tok = $ch . $tok;
}
croak "startxref marker not found" unless $tok eq 'startxref';
my $xref = $self->get_number();
croak "Invalid startxref" unless defined($xref);
eval {
$self->assert_token('%');
$self->assert_token('%');
$self->assert_token('EOF');
1;
} or do {
croak "EOF marker not found";
};
return $xref;
}
=item get_string
Reads a string from the file. A string is a sequence of characters enclosed in parentheses.
=cut
sub get_string {
my ($self) = @_;
$self->assert_token('(');
return $self->_get_string();
}
=item get_hex_string
Reads a hex string from the file. A hex string is a sequence of hexadecimal digits enclosed in angle brackets with
optional whitespace between the digits. If there is an odd number of hex digits, a zero is appended to the string. The
string is then converted to binary and decrypted if necessary. If the string begins with a byte order mark (BOM), it
is converted to UTF-8.
=cut
sub get_hex_string {
my ($self) = @_;
$self->assert_token('<');
return $self->_get_hex_string();
}
=item get_array
Reads an array from the file. An array is a sequence of objects enclosed in square brackets.
=cut
sub get_array {
my ($self) = @_;
$self->assert_token('[');
return $self->_get_array();
}
=item get_dict
Reads a dictionary from the file. A dictionary is a sequence of key/value pairs enclosed in double angle brackets.
=cut
sub get_dict {
my ($self) = @_;
$self->assert_token('<<');
return $self->_get_dict();
}
=item get_name
Reads a name from the file. A name is a sequence of characters beginning with a slash. A name can contain any
character except whitespace and the characters ()<>[]{}/%. Any character except null (character code 0) may be included
in a name by writing its 2-digit hexadecimal code, preceded by the number sign character (#)
=cut
sub get_name {
my ($self) = @_;
$self->assert_token('/');
return $self->_get_name();
}
########################################################################
# Internal methods
########################################################################
sub _init {
my $self = shift;
if (ref($_[0]) eq 'SCALAR') {
# scalar ref, open it as a file
open(my $fh, '<', $_[0]) or croak "Error opening scalar as file handle: $!";
binmode($fh);
$self->{fh} = $fh;
} elsif (ref($_[0]) eq 'GLOB') {
$self->{fh} = $_[0];
} elsif (ref($_[0]) eq '' ) {
# filename
open(my $fh, '<', $_[0]) or croak "Error opening file $_[0]: $!";
binmode($fh);
$self->{fh} = $fh;
} else {
croak "Invalid file handle";
}
$self->{pos} = 0;
$self->{starting_offset} = 0;
}
sub _get_string {
my ($self) = @_;
my $fh = $self->{fh};
my $depth = 1;
my $str = '';
my $esc = 0;
while ($depth > 0) {
my $ch = getc($fh);
if ( !defined($ch) ) {
croak "Unterminated string at offset ".tell($fh);
}
if ($esc) {
if ( defined($specials{$ch}) ) {
$str .= $specials{$ch};
} elsif ($ch =~ /[0-7]/) {
my $oct = $ch;
$ch = getc($fh);
if ( $ch =~ /[0-7]/ ) {
$oct .= $ch;
$ch = getc($fh);
if ( $ch =~ /[0-7]/ ) {
$oct .= $ch;
} else {
seek($fh, -1, 1);
}
} else {
seek($fh, -1, 1);
}
$str .= chr(oct($oct));
} else {
$str .= $ch;
}
$esc = 0;
} elsif ($ch eq '\\') {
$esc = 1;
} elsif ($ch eq '(') {
$str .= $ch;
$depth++;
} elsif ($ch eq ')') {
$depth--;
$str .= $ch if $depth > 0;
} else {
$str .= $ch;
}
}
# decrypt
if ( defined($self->{crypt}) ) {
$str = $self->{crypt}->decrypt($str);
}
return wantarray ? ($str,TYPE_STRING) : $str;
}
sub _get_hex_string {
my ($self) = @_;
my $fh = $self->{fh};
my $hex = '';
while ( defined(my $ch = getc($fh)) ) {
last if $ch eq '>';
next if $ch =~ /\s/; # skip whitespace
croak "Invalid hex string at offset " . tell($fh) unless $ch =~ /[0-9a-fA-F]/;
$hex .= $ch;
}
# pad with a zero if the length is odd
$hex .= '0' if length($hex) % 2;
my $str = pack("H*",$hex);
# decrypt
if ( defined($self->{crypt}) ) {
$str = $self->{crypt}->decrypt($str);
}
return wantarray ? ($str,TYPE_STRING) : $str;
}
=item _get_array
Reads an array from the file. An array is a sequence of objects enclosed in square brackets. The file pointer is left
at the first character after the array.
=cut
sub _get_array {
my ($self) = @_;
my @array;
while () {
local $_ = $self->get_primitive();
croak "Unexpected end of file" unless defined($_);
last if $_ eq ']';
push(@array,$_);
}
return wantarray ? (\@array,TYPE_ARRAY) : \@array;
}
sub _get_dict {
my ($self) = @_;
my $fh = $self->{fh};
my @array;
while () {
local $_ = $self->get_primitive();
croak "Unexpected end of file" unless defined($_);
last if $_ eq '>>';
push(@array,$_);
}
my %dict = @array;
# From the docs: "The keyword stream that follows the stream dictionary shall be followed by an end-of-line marker
# consisting of either a CARRIAGE RETURN and a LINE FEED or just a LINE FEED, and not by a CARRIAGE
# RETURN alone."
# Unfortunately this isn't always true in real life so we have to allow:
# stream\r\n
# stream\n
# stream\r
# get_line() will handle all of these cases for us
if ( exists($dict{'/Length'})) {
# check for stream data following the dictionary
my $offset = $self->pos();
while (defined(my $line = $self->get_line())) {
next if $line =~ /^\s*$/; # skip blank lines
if ($line =~ /^\s*stream\b/) {
$dict{_stream_offset} = $self->pos();
return wantarray ? (\%dict, TYPE_STREAM) : \%dict;
}
last;
}
# not a stream dictionary
$self->pos($offset);
}
return wantarray ? (\%dict,TYPE_DICT) : \%dict;
}
sub _get_name {
my ($self) = @_;
my $fh = $self->{fh};
my $name = '/';
while ( defined(my $ch = getc($fh)) ) {
my $class = $class_map{$ch};
unless (defined($class)) {
seek($fh, -1, 1);
croak "Invalid character '$ch' at offset " . tell($fh);
}
last if $class == CHAR_SPACE;
if ( $class != CHAR_REGULAR ) {
seek($fh, -1, 1);
last;
}
$name .= $ch;
}
$name =~ s/#([0-9a-fA-F]{2})/chr(hex($1))/ge;
return wantarray ? ($name,TYPE_NAME) : $name;
}
=back
=cut
1;