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).



162
163
164
165
166
167
168
169
170
171
# File 'lib/acrofill/document.rb', line 162

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

#normalized_box(raw) ⇒ Object

A /Rect or /BBox as [llx, lly, urx, ury]: the value and each corner may be indirect, and the corners may be given in either order. Returns nil when it is not four finite numbers — a non-finite corner would subtract into a NaN width or height, which every later comparison silently passes and every later clamp raises on.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/acrofill/document.rb', line 144

def normalized_box(raw)
  box = deref(raw)
  return nil unless box.is_a?(Array) && box.size == 4

  corners = box.map { |value| deref(value) }
  return nil unless corners.all? { |corner| corner.is_a?(Numeric) && corner.to_f.finite? }

  xs = [corners[0].to_f, corners[2].to_f].sort
  ys = [corners[1].to_f, corners[3].to_f].sort
  # Corners far enough apart still overflow when subtracted, and the
  # width and height every caller derives have to be usable too.
  return nil unless (xs[1] - xs[0]).finite? && (ys[1] - ys[0]).finite?

  [xs[0], ys[0], xs[1], ys[1]]
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