Lua: PdfPageCount.lua

Obs.: eu testei com vários arquivos PDF, mas é certo que há erros à espreita. Considere-se avisado.

Versão usada nos testes de 2010-10-27:
BitBucket – PdfPageCount.lua v2010-10-27

#!/usr/bin/env lua

--[[
 Este programa mostra na tela o número de páginas de um
 arquivo PDF passado na linha de comando.
 Este código fonte é de domínio público (PUBLIC DOMAIN)
 Desenvolvido em Lua 5.1.4
 Marcus.
--]]

function isDelimiter(char)
    return string.match(char, '[]%s()<>{}/%%[]')
end

function skipSpaces(pdfFile)
    repeat
        local by = pdfFile:read(1)
    until not string.match(by, '%s')
    pdfFile:seek('cur', -1)
end

PdfTokenReader = {}
PdfTokenReader.__index = PdfTokenReader

function PdfTokenReader.new(pdfFile)
    return setmetatable({tokenBuffer = {}, pdfFile = pdfFile}, PdfTokenReader)
end

function PdfTokenReader:unreadToken(token)
    self.tokenBuffer[#self.tokenBuffer + 1] = token
end

function PdfTokenReader:nextToken()
    if #self.tokenBuffer ~= 0 then
        return table.remove(self.tokenBuffer)
    end
    local by
    while true do
        by = self.pdfFile:read(1)
        if by == '%' then
            self:skipComment()
        elseif string.match(by, '%s') then
            skipSpaces(self.pdfFile)
        else
            --self.pdfFile:seek('cur', -1)
            break
        end
    end
    local function trataWord()
        local word = self:readWord()
        if word == 'obj' or word == 'endobj' or word == 'stream'
            or word == 'endstream' or word == 'true' or word == 'false'
            or word == 'null' or word == 'xref' or word == 'trailer' then
            return {t = word}
        else
            error('Erro no PDF no byte ' .. self.pdfFile:seek() .. ' lendo ' .. word)
        end
    end
    --by = self.pdfFile:read(1)
    if string.match(by, '[%d.+-]') then
        self.pdfFile:seek('cur', -1)
        return {t = 'NUMBER', val = self:readNumber()}
    elseif by == '<' then
        by = self.pdfFile:read(1)
        if by == '<' then
            return {t = '<<'}
        else
            self.pdfFile:seek('cur', -1)
            return {t = 'HX_STRING', val = self:readHxString()}
        end
    elseif by == '>' then
        by = self.pdfFile:read(1)
        if by == '>' then
            return {t = '>>'}
        else
            self.pdfFile:seek('cur', -1)
            return {t = '>'} -- Não deve acontecer, porque o readHxString vai consumir o '>'
        end
    elseif by == '(' then
        return {t = 'STRING', val = self:readString()}
    elseif by == '/' then
        return {t = 'NAME', val = self:readName()}
    elseif by == '[' then
        return {t = '['}
    elseif by == ']' then
        return {t = ']'}
    elseif by == '{' then
        return {t = 'CODE', val = self:readCode()}
    elseif by == 'R' then
        by = self.pdfFile:read(1)
        self.pdfFile:seek('cur', -1)
        if isDelimiter(by) then
            return {t = 'R'}
        else
            self.pdfFile:seek('cur', -1)
            return trataWord()
        end
    else
        self.pdfFile:seek('cur', -1)
        return trataWord()
    end
end

function PdfTokenReader:readWord()
    local r = {}
    local by = self.pdfFile:read(1)
    while not isDelimiter(by) do
        r[#r + 1] = by
        by = self.pdfFile:read(1)
    end
    self.pdfFile:seek('cur', -1)
    return table.concat(r)
end

function PdfTokenReader:readNumber()
    local r = {}
    local by = self.pdfFile:read(1)
    while string.match(by, '[%d.+-]') do
        r[#r + 1] = by
        by = self.pdfFile:read(1)
    end
    self.pdfFile:seek('cur', -1)
    return table.concat(r)
end

function PdfTokenReader:skipComment()
    local by
    -- skip until '\r' or '\r\n' or '\n'
    repeat
        by = self.pdfFile:read(1)
        if by == '\r' then
            by = self.pdfFile:read(1)
            if by ~= '\n' then
                self.pdfFile:seek('cur', -1)
            end
            return
        end
    until by == '\n'
end

function PdfTokenReader:readCode()
    --[[Tratando código como 1 token só porque
    não estamos interessados no seu conteúdo.
    Mas é claro que o código é composto de vários tokens!]]
    local parLevel = 0
    local r = {}
    local by
    while true do
        by = self.pdfFile:read(1)
        if by == '{' then
            parLevel = parLevel + 1
            r[#r + 1] = '{'
        elseif by == '}' then
            parLevel = parLevel - 1
            if parLevel < 0 then
                return table.concat(r)
            else
                r[#r + 1] = '}'
            end
        else
            r[#r + 1] = by
        end
    end
end

function PdfTokenReader:readName()
    return self:readWord()
end

function PdfTokenReader:readHxString()
    local r = {}
    local by = self.pdfFile:read(1)
    while by ~= '>' do
        r[#r + 1] = by
        by = self.pdfFile:read(1)
    end
    return table.concat(r)
end

function PdfTokenReader:readString()
    local parLevel = 0
    local escaped = false
    local r = {}
    local by
    while true do
        by = self.pdfFile:read(1)
        if escaped then
            if by == 'n'     then r[#r + 1] = '\n'
            elseif by == 'r' then r[#r + 1] = '\r'
            elseif by == 'b' then r[#r + 1] = '\b'
            elseif by == 'f' then r[#r + 1] = '\f'
            elseif by == '(' then r[#r + 1] = '('
            elseif by == ')' then r[#r + 1] = ')'
            elseif string.match(by, '%d') then
                -- Não suportado ainda
                r[#r + 1] = '\\'
                r[#r + 1] = by
            end
            escaped = false
        elseif by == '\\' then
            escaped = true
        elseif by == '(' then
            parLevel = parLevel + 1
            r[#r + 1] = '('
        elseif by == ')' then
            parLevel = parLevel - 1
            if parLevel < 0 then
                return table.concat(r)
            end
        else
            r[#r + 1] = by
        end
    end
end

function expect(tokFound, tokExpected)
    if tokFound ~= tokExpected then
        error('Encontrou ' .. tokFound .. ' quando esperava ' .. tokExpected)
    end
end

PdfXref = {}
PdfXref.__index = PdfXref

function PdfXref.new()
    return setmetatable({objs = {}}, PdfXref)
end

function PdfXref:parseXref(tokenReader)
    local token = tokenReader:nextToken()
    expect(token.t, 'xref')
    repeat
        token = tokenReader:nextToken()
        expect(token.t, 'NUMBER')
        local startId = tonumber(token.val)

        token = tokenReader:nextToken()
        expect(token.t, 'NUMBER')
        local nrOfLines = tonumber(token.val)

        skipSpaces(tokenReader.pdfFile)
        if nrOfLines ~= 0 then
            local nrOfExpectedBytes = 20 * nrOfLines
            local bytesXref = tokenReader.pdfFile:read(nrOfExpectedBytes)
            self:addBlock(startId, nrOfLines, bytesXref)
        end
        local by = tokenReader.pdfFile:read(1)
        tokenReader.pdfFile:seek('cur', -1)
    until not string.match(by, '%d')
end

function PdfXref:addBlock(startId, nrOfLines, xrefBlock)
    for i=0, nrOfLines-1 do
        self:addObj(i + startId, string.sub(xrefBlock, 20*i + 1, 20*(i+1)))
    end
end

function PdfXref:addObj(id, xrefLine)
    local xrefItem = {}
    xrefItem.type = string.sub(xrefLine, 18, 18)
    xrefItem.id = id
    if xrefItem.type == 'n' then
        xrefItem.bytePos = tonumber(string.sub(xrefLine, 1, 10))
    end
    xrefItem.genNum = tonumber(string.sub(xrefLine, 12, 16))
    -- vamos usar uma string para a chave composta...
    local ref = xrefItem.id .. ' ' .. xrefItem.genNum
    if self.objs[ref] == nil then
        self.objs[ref] = xrefItem
    end
end

function PdfXref:getObj(ref)
    return self.objs[ref.id .. ' ' .. ref.genNum]
end

function parseObject(tokenReader)
    local function obj()
        local result = parseObject(tokenReader)
        local token = tokenReader:nextToken()
        expect(token.t, 'endobj')
        return result
    end
    local token1 = tokenReader:nextToken()
    if token1.t == 'NUMBER' then
        local token2 = tokenReader:nextToken()
        if token2.t == 'NUMBER' then
            local token3 = tokenReader:nextToken()
            if token3.t == 'obj' then
                return obj()
            elseif token3.t == 'R' then
                return {id = tonumber(token1.val), genNum = tonumber(token2.val)}
            else
                tokenReader:unreadToken(token3)
                tokenReader:unreadToken(token2)
                return token1
            end
        else
            tokenReader:unreadToken(token2)
            return token1
        end
    elseif token1.t == '[' then
        return parseArray(tokenReader)
    elseif token1.t == '<<' then
        local dict = parseDict(tokenReader)
        local token4 = tokenReader:nextToken()
        if token4.t == 'stream' then
            return readStream(tokenReader, dict)
        else
            tokenReader:unreadToken(token4)
            return dict
        end
    elseif token1.t == 'obj' then
        return obj()
    else
        return token1
    end
end

function parseDict(tokenReader)
    local result = {}
    while true do
        local keyToken = tokenReader:nextToken()
        if keyToken.t == '>>' then
            return result
        else
            local objeto = parseObject(tokenReader)
            if objeto.t == '>>' then
                error('>> inesperado em pos = ' .. tokenReader.pdfFile:seek())
            else
                result[keyToken.val] = objeto
            end
        end
    end
end

function parseArray(tokenReader)
    local r = {}
    while true do
        local token = tokenReader:nextToken()
        if token.t == ']' then
            return r
        end
        tokenReader:unreadToken(token)
        r[#r + 1] = parseObject(tokenReader)
    end
end

function readStream(tokenReader, dict)
    local by = tokenReader.pdfFile:read(1)
    if by == '\r' then
        tokenReader.pdfFile:read(1) -- leu \r, ignorar \n
    elseif by == '\n' then
        -- ignorar \n
    else
        tokenReader.pdfFile:seek('cur', -1)
    end
    local len = dict.Length
    if len.t == nil and len.id ~= nil then -- referência
        local savedPos = tokenReader.pdfFile:seek()
        len = findIndirectObj(tokenReader, len)
        tokenReader.pdfFile:seek('set', savedPos)
    end
    tokenReader.pdfFile:seek('cur', len.val) -- agora devemos ter um NUMBER
    local token = tokenReader:nextToken()
    expect(token.t, 'endstream')
    return {dict = dict, bytes = ''} -- só fizemos de conta que lemos o stream
end

function readIndirectObj(tokenReader, xrefItem)
    if xrefItem.type == 'n' then
        tokenReader.pdfFile:seek('set', xrefItem.bytePos)
        return parseObject(tokenReader)
    else
        error('Procurando objeto inválido ' .. xrefItem.id)
    end
end

function findIndirectObj(tokenReader, ref)
    return readIndirectObj(tokenReader, tokenReader.xref:getObj(ref))
end

function parseTrailer(tokenReader)
    local token = tokenReader:nextToken()
    expect(token.t, 'trailer')
    token = tokenReader:nextToken()
    expect(token.t, '<<')
    return parseDict(tokenReader)
end

function processFile(file)
    local comecoPdf = file:read(5)
    if comecoPdf ~= '%PDF-' then
        error('Não é um arquivo PDF')
    end
    local endBufferSize = 400
    local fileSize = file:seek('end', 0)
    if fileSize < endBufferSize then
        endBufferSize = fileSize
    end
    file:seek('end', -endBufferSize)
    local startxref = file:read(endBufferSize)
    local matchResult = string.match(startxref, 'startxref%s+(%d+)%s+%%%%EOF')
    if not matchResult then
        error('Não achou o número de páginas (startxref)')
    end
    local tokenReader = PdfTokenReader.new(file)
    local pos = tonumber(matchResult)
    local function linearized()
        file:seek('set', 0)
        local firstObj = parseObject(tokenReader)
        if firstObj.Linearized ~= nil then
            return tonumber(firstObj.N.val)
        else
            error('Não achou o número de páginas (/Linearized)')
        end
    end
    if pos == 0 then
        return linearized()
    end
    file:seek('set', pos)
    local token = tokenReader:nextToken()
    if token.t == 'xref' then
        tokenReader:unreadToken(token)
        local trailers = {}
        local xref = PdfXref.new()
        tokenReader.xref = xref
        while true do
            xref:parseXref(tokenReader)
            local trailer = parseTrailer(tokenReader)
            trailers[#trailers + 1] = trailer
            local prev = trailer.Prev
            if prev ~= nil then
                file:seek('set', tonumber(prev.val))
            else
                break
            end
        end
        local catalog
        for i=1, #trailers do
            local root = trailers[i].Root
            if root ~= nil then
                catalog = findIndirectObj(tokenReader, root)
                break
            end
        end
        if catalog == nil then
            error('Não achou o número de páginas (catalog)')
        end
        local pagesRef = catalog.Pages
        local pages = findIndirectObj(tokenReader, pagesRef)
        return tonumber(pages.Count.val)
    else
        return linearized()
    end
end


function getNumberOfPages(fileName)
    local file, msg = io.open(fileName, 'rb')
    if not file then
        error(msg)
    end
    local status, result = pcall(processFile, file)
    file:close()
    if status then
        return result
    else
        error(result)
    end
end

if #arg < 1 then
    print('Uso: PdfPageCount <nome-arquivo.pdf>')
else
    local sucessos, falhas = 0, 0
    for i=1, #arg do
        local status, result = pcall(getNumberOfPages, arg[i])
        if status then
            print('Número de páginas de ' .. arg[i] .. ': ' .. result)
            sucessos = sucessos + 1
        else
            print(result)
            print('Ocorreu um erro ao processar ' .. arg[i] .. '. Continuando...')
            falhas = falhas + 1
        end
        --io.flush()
    end
    if #arg > 1 then
        print('Fim do processo. ' .. sucessos .. ' sucesso(s), ' .. falhas .. ' falha(s).')
    end
end

Anúncios

Um pensamento sobre “Lua: PdfPageCount.lua

  1. Comparação de desempenho (2) « Visions of hope

Deixe um comentário

Preencha os seus dados abaixo ou clique em um ícone para log in:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s