3

I'm trying to get links and their destinations from external pdf. Using TL2018 luatex version 1.07. Documentation points to getAnnots and getLinks but in this version they are undefined.

<Object> = <Page>:getAnnots()
<Links> = <Page>:getLinks(<Catalog>)

errors:

attempt to call method 'getLinks' (a nil value)
attempt to call method 'geAnnots' (a nil value)

I can get a Links object with <PDFDoc>:getLinks() but this supports only one method getNumLinks.I checked <Catalog>:getDests() but its returned object is nil.
<Catalog>:findDest(<name>) found destination but I need to know all the names. Is there any way to find out available methods of objects? How can I get links and their destinations? Any hints would be very helpful.

local function parse_links (pdf)
    local doc = epdf.open(pdf)
    local cat = doc:getCatalog()
    local anns = cat:findDest("a1.label")
    print (anns:getKindName())               -- output "FitH"
    local pages_num = doc:getNumPages()
    local page_num  = 1
    print ('Pages: ' .. pages_num)

    while page_num <= pages_num do
        local page = cat:getPage(page_num)
        -- local links = page:getLinks(cat)  -- error "attempt to call method 'getLinks' (a nil value)"
        -- local annots = page:getAnnots()  -- error "attempt to call method 'geAnnots' (a nil value)"
        local links = doc:getLinks(page_num)
        local dests = cat:getDests()
        print (dests, dests:isNull())        -- output "Object: 002DE700        true"
        num_links = links:getNumLinks()
        print ("num links:", num_links)

        page_num = page_num + 1
    end
end

Output:

FitH
Pages: 3
Object: 00B3DF20        true
num links:      2
Object: 00B3DF20        true
num links:      3
Object: 00B3DF20        true
num links:      3
  • 1
    I would avoid using epdf for anything. The library does no longer exists in newer versions of LuaTeX and is replaced by pdfe which is more low-level. Also as you noticed, the documentation of epdf is imprecise at best. So basically page:getLinks is not implemented, page:getAnnots is actually named page:getAnnotsObject but is almost useless anyway because you can get basically no information from a Annot object. – Marcel Krüger Jan 31 at 12:56
  • Marcel, my fingers was crossed while waiting your answer because you already answered several questions using epdf :) – Linuxss Jan 31 at 13:05
4

To access all the links on a page, you can use getAnnotsObject because every link is an annotation. The undocumented getAnnotsObject is like the decumented getAnnots except that it actually exists. Attention: This is a <Object> containing an array, not the Annots type.

Then you have to parse the Annots manually to determine which annotations are links: Just look into the Subtype field of the Annotation directory. If you find an annotation dictionary with subtype Link, you can try to determine the destination. According to the PDF specification this is either in Dest or in a action dictionary in A. A could also contain other kinds of action, but we ignore these. Also some destinations might be specified directly without a name, these can not easily be wrapped in a LinkDest object:

local function parse_links (pdf)
  local doc = epdf.open(pdf)
  local cat = doc:getCatalog()

  for page_num=1,doc:getNumPages() do
    print("Analysing page " .. page_num .. ".")
    local page = cat:getPage(page_num)
    local annots = page:getAnnotsObject():getArray()
    if annots then
      for i = 1,annots:getLength() do
        local annot = annots:get(i)
        annot = annot and annot:getDict()
        if annot and annot:lookup"Subtype":getName() == "Link" then
          local dest = annot:lookup"Dest"
          if dest:isNull() then
            local a = annot:lookup"A":getDict()
            if a and a:lookup"S":getName() == "GoTo" then
              dest = a:lookup"D"
            end
          end
          if dest:isString() or dest:isName() then
            dest = cat:findDest(dest:getString() or dest:getName())
          else
            -- In this case we get a direct reference to the destination array
            -- This is *NOT* a LinkDest object, but all the information could be extracted manually
            -- This case will not appear with LaTeX/hyperref documents AFAICT, so we ignore it
          end
          local rect = annot:lookup"Rect":getArray()
          -- Let's print the position on the page and the destination object
          print(rect:get(1):getNum(), rect:get(2):getNum(), rect:get(3):getNum(), rect:get(4):getNum(), dest)
        end
      end
    end
  end
end

As you can see the epdf library is very verbose and it feels like half of the implementation is missing. Also some stuff might not even be possible: For example you can access the Dests list through :getDests, but modern PDF documents contain their destinations in the Name tree aand not in Dests. There does not seem to be any way to access the name tree directly.

For current versions of LuaTeX, epdf has been replaced by pdfe. The pdfe library works on a lower level: It does not know anything about annotations, links or the name tree, instead it provides access to the raw PDF objects and you have to interpret them yourself. For example all links with their destination could be listed using pdfe with

local function lookup_name(tree, name)
  -- A PDF name tree is a tree of multiple arrays with name object pairs,
  -- where each node documents the names it is responsible for using /Limits
  -- For details, read the PDF specification
  local sub = tree.Limits
  if sub then
    if name < sub[1] or name > sub[2] then
      return
    end
  end
  sub = tree.Names
  if sub then
    for i = 1,#sub,2 do
      if sub[i] == name then return sub[i+1] end
    end
    return false
  end
  sub = tree.Kids
  for i = 1,#sub do
    local r = lookup_name(sub[i], name)
    if r ~= nil then
      return r
    end
  end
  return false
end
local dest_to_string_workers = { -- How to print the destination, you could add e.g. FitH
  XYZ = function(p, d)
    return ("page %i at position (%f,%f), zoom factor %s"):format(p, d[3], d[4], d[5] or "none")
  end,
}
local function dest_to_string(d, dest)
  if dest.D then dest = dest.D end
  local page_no
  for i = 1,#d.Pages do
    if tostring(d.Pages[i]) == tostring(dest[1]) then
      page_no = i
      break
    end
  end
  return (dest_to_string_workers[dest[2]] or function()return""end)(page_no, dest)
end
function show_links(filename)
  local d = pdfe.open(filename)
  if not d then error(([[Unable to open "%s".]]):format(filename)) end
  for i=1,#d.Pages do
    local annots = d.Pages[i].Annots
    if annots then -- We have annotations on this page
      for j = 1,#annots do
        local annot = annots[j]
        if annot.Subtype == "Link" then -- We found a link
          local dest = annot.Dest or (annot.A and annot.A.S == "GoTo" and annot.A.D)
          if dest then
            if type(dest) == 'string' then
              local dest_name = dest
              dest = lookup_name(d.Catalog.Names.Dests, dest)
              print("On page " .. i .. " at position (" .. annot.Rect[1] .. "," .. annot.Rect[2] .. ") we found a link with destination \"" .. dest_name .. "\" on " .. dest_to_string(d, dest))
            else
              print("On page " .. i .. " at position (" .. annot.Rect[1] .. "," .. annot.Rect[2] .. ") we found a link with destination on " .. dest_to_string(d, dest))
            end
          else
            print[[This destination is not supported...]]
          end
        end
      end
    end
  end
end

pdfe also allows to iterate over the name tree to list all destination tree:

local function show_name_list(tree)
  local sub = tree.Names
  if sub then
    for i = 1,#sub,2 do
      print(sub[i])
    end
    return
  end
  sub = tree.Kids
  for i = 1,#sub do
    show_name_list(sub[i])
  end
end

function show_dests(filename)
  local d = pdfe.open(filename)
  if not d then error(([[Unable to open "%s".]]):format(filename)) end
  show_name_list(d.Catalog.Names.Dests)
end
  • That was totally AMAZING!!! I'm shore this answer will be useful for many users. luatex 1.09.2 still has no pdfe library (the newest version which I have). So I didn't try the other example but first one works perfectly. – Linuxss Feb 1 at 10:43
  • 1
    Yes, used wrong version. Script needs one more check after assigned dest. if dest then dest becomes true with annot.Dest or annot.A but script uses only annot.A.D so it breaks if dest was from annot.Dest. But anyway this is absolutely perfect! – Linuxss Feb 1 at 12:14
  • Sorry some how deleted your comment. – Linuxss Feb 1 at 12:16
  • @Linuxss Can you try the updated version? I fixed the Dest problem by actually using dest after assigning it... Anyway do not worry about the comment, I deleted it myself: I considered it no longer relevant after your answer. – Marcel Krüger Feb 1 at 12:22
  • Works like a charm! :) – Linuxss Feb 1 at 12:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.