Class: Acrofill::Document

Inherits:
Object
  • Object
show all
Defined in:
lib/acrofill/document.rb

Overview

In-memory object store for a parsed PDF. All indirect objects are materialized once via pdf-reader (which transparently handles xref streams and object streams), then mutated in place before writing.

Constant Summary collapse

PAGE_NODE_TYPES =
%i[Pages Page].freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(path) ⇒ Document

Returns a new instance of Document.

Raises:



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/acrofill/document.rb', line 18

def initialize(path)
  hash = parse_boundary { PDF::Reader::ObjectHash.new(path) }
  raise Error, 'encrypted PDFs are not supported' if hash.trailer[:Encrypt]

  @objects = {}
  @max_oid = 0
  # ObjectHash parses object bodies lazily, so the iteration that
  # materializes them must also stay inside the parse boundary.
  parse_boundary do
    hash.each do |ref, obj|
      @objects[ref.id] = obj
      @max_oid = ref.id if ref.id > @max_oid
    end
  end
  @trailer = hash.trailer.slice(:Root, :Info, :ID)
  raise Error, 'PDF has no document catalog' unless @trailer[:Root]
end

Instance Attribute Details

#objectsObject (readonly)

Returns the value of attribute objects.



16
17
18
# File 'lib/acrofill/document.rb', line 16

def objects
  @objects
end

#trailerObject (readonly)

Returns the value of attribute trailer.



16
17
18
# File 'lib/acrofill/document.rb', line 16

def trailer
  @trailer
end

Class Method Details

.restore(snapshot) ⇒ Object

Rebuilds a Document from a snapshot produced by #snapshot. Restoring deep-copies every object, so mutations never leak between fills. Snapshots are an internal format: only feed this data produced by #snapshot in the same process (Marshal is not safe on foreign input).



45
46
47
48
49
50
51
52
# File 'lib/acrofill/document.rb', line 45

def self.restore(snapshot)
  doc = allocate
  # Snapshots are produced by #snapshot in the same process and never
  # accepted from external input, so Marshal here is not a deserialization
  # boundary (see Template docs).
  doc.send(:restore_state, *Marshal.load(snapshot)) # rubocop:disable Security/MarshalLoad
  doc
end

Instance Method Details

#add(obj) ⇒ Object

Adds a new indirect object and returns a reference to it.



90
91
92
93
94
# File 'lib/acrofill/document.rb', line 90

def add(obj)
  @max_oid += 1
  @objects[@max_oid] = obj
  PDF::Reader::Reference.new(@max_oid, 0)
end

#deref(obj) ⇒ Object

Follows reference chains (ref -> ref -> value is legal PDF), with a hop cap so a reference cycle cannot loop forever.



79
80
81
82
83
84
85
86
87
# File 'lib/acrofill/document.rb', line 79

def deref(obj)
  hops = 0
  while obj.is_a?(PDF::Reader::Reference)
    return nil if (hops += 1) > 16

    obj = @objects[obj.id]
  end
  obj
end

#each_pageObject

Depth-first, document-order walk over the /Pages tree. Iterative with an explicit stack (a deep linear chain must not overflow the Ruby stack) and a visited set (a cyclic or shared billion-laughs tree must not hang or amplify).



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/acrofill/document.rb', line 115

def each_page
  stack = [root[:Pages]]
  seen = {}
  until stack.empty?
    node = stack.pop
    dict = deref(node)
    next unless dict.is_a?(Hash)

    if node.is_a?(PDF::Reader::Reference)
      next if seen[node.id]

      seen[node.id] = true
    end

    case node_type(dict)
    when :Pages
      kids = deref(dict[:Kids])
      stack.concat(kids.reverse) if kids.is_a?(Array)
    when :Page
      yield dict
    end
  end
end

#inherited_value(node, key) ⇒ Object

Resolves an attribute inheritable through the /Parent chain (field attributes like /FT, /DA, /Q or page attributes).



141
142
143
144
145
146
147
148
149
150
# File 'lib/acrofill/document.rb', line 141

def inherited_value(node, key)
  seen = 0
  while node.is_a?(Hash)
    return node[key] if node.key?(key)
    return nil if (seen += 1) > 64 # cycle guard

    node = deref(node[:Parent])
  end
  nil
end

#ref_for(obj) ⇒ Object

Wraps a direct object into an indirect one; passes references through.



97
98
99
# File 'lib/acrofill/document.rb', line 97

def ref_for(obj)
  obj.is_a?(PDF::Reader::Reference) ? obj : add(obj)
end

#rootObject

The document catalog. The trailer may point at a missing or mistyped object, so this is a parse-boundary check too: callers get an Acrofill::Error rather than a NoMethodError on nil.

Raises:



104
105
106
107
108
109
# File 'lib/acrofill/document.rb', line 104

def root
  node = deref(@trailer[:Root])
  raise Error, 'PDF has no document catalog' unless node.is_a?(Hash)

  node
end

#snapshotObject

A serialized copy of the pristine object graph; see Template.



37
38
39
# File 'lib/acrofill/document.rb', line 37

def snapshot
  Marshal.dump([@objects, @trailer, @max_oid])
end