diff options
Diffstat (limited to 'plugins/pdf.lua')
-rw-r--r-- | plugins/pdf.lua | 514 |
1 files changed, 514 insertions, 0 deletions
diff --git a/plugins/pdf.lua b/plugins/pdf.lua new file mode 100644 index 0000000..91f8b6b --- /dev/null +++ b/plugins/pdf.lua @@ -0,0 +1,514 @@ +-- +-- pdf.lua: Portable Document Format +-- +-- Based on PDF Reference, version 1.7 +-- In practice almost useless, I just wanted to learn about the file format. +-- FIXME: it's also not very robust and doesn't support all documents. +-- +-- Copyright (c) 2017, Přemysl Janouch <p.janouch@gmail.com> +-- +-- Permission to use, copy, modify, and/or distribute this software for any +-- purpose with or without fee is hereby granted, provided that the above +-- copyright notice and this permission notice appear in all copies. +-- +-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +-- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +-- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +-- SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +-- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +-- OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +-- CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +-- + +local oct_alphabet = "01234567" +local dec_alphabet = "0123456789" +local hex_alphabet = "0123456789abcdefABCDEF" +local whitespace = "\x00\t\n\f\r " +local delimiters = "()<>[]{}/%" + +local strchr = function (s, ch) return s:find (ch, 1, true) end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local Lexer = {} +Lexer.__index = Lexer + +function Lexer:new (c) + return setmetatable ({ c = c }, self) +end + +-- TODO: make it possible to follow a string, we should probably be able to +-- supply callbacks to the constructor, or a wrapper object; +-- this will be used for object streams +function Lexer:getc () + if self.c.eof then return nil end + return self.c:read (1) +end + +function Lexer:ungetc () + self.c.position = self.c.position - 1 +end + +function Lexer:token (type, value, description) + if description then + self.c (self.start, self.c.position - 1):mark (description) + end + return { type=type, value=value, + start=self.start, stop=self.c.position - 1 } +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +function Lexer:eat_newline (ch) + if ch == '\r' then + ch = self:getc () + if ch and ch ~= '\n' then self:ungetc () end + return true + elseif ch == '\n' then + return true + end +end + +function Lexer:string () + local value, level, ch = "", 1 +::continue:: + while true do + ch = self:getc () + if not ch then return nil + elseif ch == '\\' then + ch = self:getc () + if not ch then return nil + elseif ch == 'n' then ch = '\n' + elseif ch == 'r' then ch = '\r' + elseif ch == 't' then ch = '\t' + elseif ch == 'b' then ch = '\b' + elseif ch == 'f' then ch = '\f' + elseif self:eat_newline (ch) then goto continue + elseif strchr (oct_alphabet, ch) then + local buf, i = ch + for i = 1, 2 do + ch = self:getc () + if not ch then return nil + elseif not strchr (oct_alphabet, ch) then + self:ungetc () + break + end + buf = buf .. ch + end + ch = string.char (tonumber (buf, 8)) + end + elseif self:eat_newline (ch) then + ch = '\n' + elseif ch == '(' then + level = level + 1 + elseif ch == ')' then + level = level - 1 + if level == 0 then break end + end + value = value .. ch + end + return self:token ('string', value, "string literal") +end + +function Lexer:string_hex () + local value, buf, ch = "" + while true do + ch = self:getc () + if not ch then return nil + elseif ch == '>' then + break + elseif not strchr (hex_alphabet, ch) then + return nil + elseif buf then + value = value .. string.char (tonumber (buf .. ch, 16)) + buf = nil + else + buf = ch + end + end + if buf then value = value .. string.char (tonumber (buf .. '0', 16)) end + return self:token ('string', value, "string hex") +end + +function Lexer:name () + local value, ch = "" + while true do + ch = self:getc () + if not ch then break + elseif ch == '#' then + local ch1, ch2 = self:getc (), self:getc () + if not ch1 or not ch2 + or not strchr (hex_alphabet, ch1) + or not strchr (hex_alphabet, ch2) then + return nil + end + ch = string.char (tonumber (ch1 .. ch2, 16)) + elseif strchr (whitespace .. delimiters, ch) then + self:ungetc () + break + end + value = value .. ch + end + if value == "" then return nil end + return self:token ('name', value, "name") +end + +function Lexer:comment () + local value, ch = "" + while true do + ch = self:getc () + if not ch then break + elseif ch == '\r' or ch == '\n' then + self:ungetc () + break + end + value = value .. ch + end + return self:token ('comment', value, "comment") +end + +function Lexer:number (ch) + local value, real, digits = "", false, false + if ch == '-' then + value = ch + ch = self:getc () + end + while ch do + if strchr (dec_alphabet, ch) then + digits = true + elseif ch == '.' and not real then + real = true + else + self:ungetc () + break + end + value = value .. ch + ch = self:getc () + end + -- XXX: perhaps we should instead let it be interpreted as a keyword + if not digits then return nil end + -- XXX: maybe we should differentiate between integers and real values + return self:token ('number', tonumber (value, 10), "number") +end + +function Lexer:get_token () +::restart:: + self.start = self.c.position + local ch = self:getc () + + if not ch then return nil + elseif ch == '(' then return self:string () + elseif ch == '[' then return self:token ('begin_array') + elseif ch == ']' then return self:token ('end_array') + elseif ch == '<' then + -- It seems they ran out of paired characters, yet {} is unused + ch = self:getc () + if not ch then return nil + elseif ch == '<' then return self:token ('begin_dictionary') + else + self:ungetc () + return self:string_hex () + end + elseif ch == '>' then + ch = self:getc () + if not ch then return nil + elseif ch == '>' then return self:token ('end_dictionary') + else return nil end + elseif ch == '/' then return self:name () + elseif ch == '%' then return self:comment () + elseif strchr ("-0123456789.", ch) then return self:number (ch) + elseif self:eat_newline (ch) then return self:token ('newline') + elseif strchr (whitespace, ch) then goto restart + else + -- {} end up being keywords but we should probably error out + local value = ch + while true do + ch = self:getc () + if not ch then break + elseif strchr (whitespace .. delimiters, ch) then + self:ungetc () + break + end + value = value .. ch + end + if value == "null" then + return self:token ('null', nil, "null") + elseif value == "true" then + return self:token ('boolean', true, "boolean") + elseif value == "false" then + return self:token ('boolean', false, "boolean") + end + return self:token ('keyword', value, "keyword") + end +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local is_value = function (t) + return t == 'null' or t == 'boolean' or t == 'name' + or t == 'number' or t == 'string' +end + +-- Retrieve the next thing in the stream, possibly popping values from the stack +local function get_object (lex, stack, deref) +::restart:: + local token = lex:get_token () + if token == nil then return nil + elseif token.type == 'begin_array' then + local array = {} + repeat + local object = get_object (lex, array, deref) + if not object then error ("array doesn't end") end + table.insert (array, object) + until object.type == 'end_array' + local stop = table.remove (array) + return { type='array', value=array, start=token.start, stop=stop.stop } + elseif token.type == 'begin_dictionary' then + local dict = {} + repeat + local object = get_object (lex, dict, deref) + if not object then error ("dictionary doesn't end") end + table.insert (dict, object) + until object.type == 'end_dictionary' + local stop, kv = table.remove (dict), {} + if #dict % 2 == 1 then error ("unbalanced dictionary") end + for i = 1, #dict, 2 do + local k, v = dict[i], dict[i + 1] + if k.type ~= 'name' then error ("invalid dictionary key type") end + kv[k.value] = v + end + return { type='dict', value=kv, start=token.start, stop=stop.stop } + elseif token.type == 'keyword' and token.value == 'stream' then + if #stack < 1 then error ("no dictionary for stream") end + local d = table.remove (stack) + if d.type ~= 'dict' then error ("stream not preceded by dictionary") end + + if not lex:eat_newline (lex:getc ()) then + error ("'stream' not followed by newline") + end + + local len = deref (d.value['Length']) + if not len or len.type ~= 'number' then + error ("missing stream length") + end + + local data, stop = lex.c:read (len.value), get_object (lex, {}, deref) + if not stop or stop.type ~= 'keyword' or stop.value ~= 'endstream' then + error ("missing 'endstream'") + end + + return { type='stream', value={ dict=dict, data=data }, + start=token.start, stop=stop.stop } + elseif token.type == 'keyword' and token.value == 'obj' then + if #stack < 2 then error ("missing object ID pair") end + local gen, n = table.remove (stack), table.remove (stack) + if n.type ~= 'number' or gen.type ~= 'number' then + error ("object ID pair must be two integers") + end + + local tmp = {} + repeat + local object = get_object (lex, tmp, deref) + if not object then error ("object doesn't end") end + table.insert (tmp, object) + until object.type == 'keyword' and object.value == 'endobj' + local stop = table.remove (tmp) + + if #tmp ~= 1 then error ("objects must contain exactly one value") end + local value = table.remove (tmp) + return { type='object', n=n.value, gen=gen.value, value=value, + start=n.start, stop=stop.stop } + elseif token.type == 'keyword' and token.value == 'R' then + if #stack < 2 then error ("missing reference ID pair") end + local gen, n = table.remove (stack), table.remove (stack) + if n.type ~= 'number' or gen.type ~= 'number' then + error ("reference ID pair must be two integers") + end + return { type='reference', value={ n.value, gen.value } } + elseif token.type == 'newline' or token.type == 'comment' then + -- These are not objects and our callers aren't interested + goto restart + else + return token + end +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local detect = function (c) + return c:read (5) == "%PDF-" +end + +local decode_xref_subsection = function (lex, start, count, result) + if not lex:eat_newline (lex:getc ()) then + error ("xref subsection must start on a new line") + end + for i = 0, count - 1 do + local entry = lex.c:read (20) + local off, gen, typ = entry:match + ("^(%d%d%d%d%d%d%d%d%d%d) (%d%d%d%d%d) ([fn])[\r ][\r\n]$") + if not off then error ("invalid xref entry") end + + -- Translated to the extended XRefStm format + result[start + i] = { + t = typ == 'n' and 1 or 0, + o = math.tointeger (off), + g = math.tointeger (gen), + } + end +end + +-- A deref that can't actually resolve anything, for early stages of processing +local deref_nil = function (x) + if not x or x.type == 'reference' then return nil end + return x +end + +-- Creates a table with named indexes from the trailer and items indexed by +-- object numbers containing { XRefStm fields... } +local decode_xref_normal = function (lex) + local result = {} + while true do + local a = get_object (lex, {}, deref_nil) + local b = get_object (lex, {}, deref_nil) + if not a or not b then + error ("xref section ends too soon") + elseif a.type == 'number' and b.type == 'number' then + decode_xref_subsection (lex, a.value, b.value, result) + elseif a.type == 'keyword' and a.value == 'trailer' + and b.type == 'dict' then + for k, v in pairs (b.value) do + result[k] = v + end + return result + else + error ("invalid xref contents") + end + end +end + +local decode_xref_stream = function (lex, stream) + if stream.dict['Type'] ~= 'XRef' then error ("expected an XRef stream") end + + -- TODO: decode a cross-reference stream from stream.{dict,data}; + -- the compression filter, if present, is always going to be FlateDecode, + -- which we'll have to import or implement + -- TODO: take care to also cache cross-reference streams by offset when + -- they're actually implemented + error ("cross-reference streams not implemented") +end + +local decode_xref = function (c) + local lex, stack = Lexer:new (c), {} + while true do + local object = get_object (lex, stack, deref_nil) + if object == nil then + return nil + elseif object.type == 'keyword' and object.value == 'xref' then + return decode_xref_normal (lex) + elseif object.type == 'stream' then + return decode_xref_stream (lex, object) + end + table.insert (stack, object) + end +end + +-- Return all objects found in xref tables as a table indexed by object number, +-- pointing to a list of generations and overwrites, from newest to oldest. +local read_all_xrefs = function (c, start_offset) + local loaded, result, offset = {}, {}, start_offset + while true do + -- Prevent an infinite loop with malicious files + if loaded[offset] then error ("cyclic cross-reference sections") end + + local xref = decode_xref (c (1 + offset, #c)) + if not xref then break end + for k, v in pairs (xref) do + if type (k) == 'number' then + if not result[k] then result[k] = {} end + table.insert (result[k], v) + end + end + loaded[offset] = true + + -- TODO: when 'XRefStm' is found, it has precedence over this 'Prev', + -- and also has its own 'Prev' chain + local prev = xref['Prev'] + if not prev or prev.type ~= 'number' then break end + offset = prev.value + end + return result +end + +local decode = function (c) + assert (c.position == 1) + if not detect (c ()) then error ("not a PDF file") end + + -- Look for a pointer to the xref section within the last kibibyte + -- NOTE: we could probably look backwards for the "trailer" line from here + -- but we don't know how long the trailer is and we don't want to regex + -- scan the whole file (ignoring that dictionary contents might, possibly + -- legally, include the word "trailer" at the beginning of a new line) + local tail_len = math.min (1024, #c) + local tail = c (#c - tail_len, #c):read (tail_len) + local xref_loc = tail:match (".*%sstartxref%s+(%d+)%s+%%%%EOF") + if not xref_loc then error ("cannot find trailer") end + + -- We need to decode xref sections in order to be able to resolve indirect + -- references to stream lengths + local xref = read_all_xrefs (c, math.tointeger (xref_loc)) + local deref + + -- We have to make sure that we don't decode objects twice as that would + -- duplicate all marks, so we simply cache all objects by offset. + -- This may be quite the memory load but it seems to be the best thing. + local cache = {} + local read_object = function (offset) + if cache[offset] then return cache[offset] end + + local lex, stack = Lexer:new (c (1 + offset, #c)), {} + repeat + local object = get_object (lex, stack, deref) + if not object then error ("object doesn't end") end + table.insert (stack, object) + until object.type == 'object' + + local object = table.remove (stack) + cache[offset] = object + c (offset + object.start, offset + object.stop) + :mark ("object " .. object.n .. " " .. object.gen) + return object + end + + -- Resolve an object -- if it's a reference, look it up in "xref", + -- otherwise just return the object as it was passed + deref = function (x) + if not x or x.type ~= 'reference' then return x end + local n, gen = x.value[1], x.value[2] + + -- TODO: we should also ignore object numbers >= trailer /Size + local bin = xref[n] + if not bin then return nil end + local entry = bin[1] + if not entry or entry.t ~= 1 or entry.g ~= gen then return nil end + + local object = read_object (entry.o) + if not object or object.n ~= n or object.gen ~= gen then return nil end + return object.value + end + + -- Read all objects accessible from the current version of the document + for n, bin in pairs (xref) do + local entry = bin[1] + if entry and entry.t == 1 then + read_object (entry.o) + end + end + + -- TODO: we should actually try to decode even unreferenced objects. + -- The problem with decoding content from previous versions of the + -- document is that we must ignore xref updates from newer versions. + -- The version information needs to be propagated everywhere. +end + +hex.register { type="pdf", detect=detect, decode=decode } |