Class: PdfOxide::PdfDocument

Inherits:
Object
  • Object
show all
Defined in:
lib/pdf_oxide/pdf_document.rb

Overview

The primary read-only entry point to a PDF.

Mirrors fyi.oxide.pdf.PdfDocument. Lifecycle: a PdfDocument owns native memory and must be closed when no longer in use. The idiomatic Ruby pattern is the block form PdfDocument.open(path) do |doc| ... end which closes automatically; for parity with the Java AutoCloseable contract, an explicit #close is also supported and is idempotent (a second call is a no-op, not a crash).

A Finalizer backstop frees leaked handles on GC; callers must not rely on it for timely cleanup.

Examples:

block form (recommended)

PdfOxide::PdfDocument.open('invoice.pdf') do |doc|
  puts doc.extract_text(0)
end

explicit close

doc = PdfOxide::PdfDocument.open('invoice.pdf')
begin
  puts doc.extract_text(0)
ensure
  doc.close
end

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source, password: nil) ⇒ PdfDocument

Open a PDF. See open for the block-form factory.



65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/pdf_oxide/pdf_document.rb', line 65

def initialize(source, password: nil)
  raise ::PdfOxide::ArgumentError, 'source cannot be nil' if source.nil?

  @path, @handle = open_native(source)
  @closed = false
  # Mutable tracker lets an explicit `#close` defuse the finalizer
  # so the GC pass doesn't double-free.
  @tracker = [@handle]
  ObjectSpace.define_finalizer(self, self.class.finalizer(@tracker))

  authenticate(password) if password
end

Instance Attribute Details

#pathString (readonly)

Returns absolute path the document was opened from (or a synthetic <in-memory> token for byte-opened docs).

Returns:

  • (String)

    absolute path the document was opened from (or a synthetic <in-memory> token for byte-opened docs).



31
32
33
# File 'lib/pdf_oxide/pdf_document.rb', line 31

def path
  @path
end

Class Method Details

.extract_text(source, page: 0) ⇒ String

One-shot: open + extract page text + close.

Parameters:

  • source (String)

    path or bytes (see #open).

  • page (Integer) (defaults to: 0)

    0-based page index (default 0).

Returns:

  • (String)

    extracted text.



58
59
60
61
62
# File 'lib/pdf_oxide/pdf_document.rb', line 58

def self.extract_text(source, page: 0)
  # rubocop:disable Security/Open — PdfDocument.open opens a PDF, not a process.
  open(source) { |d| d.extract_text(page) }
  # rubocop:enable Security/Open
end

.finalizer(tracker) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Finalizer for GC cleanup. The mutable tracker lets explicit #close zero out the handle so a follow-up GC pass doesn't double-free (the cdylib's pdf_document_free is not idempotent on the same pointer).



399
400
401
402
403
404
405
406
407
# File 'lib/pdf_oxide/pdf_document.rb', line 399

def self.finalizer(tracker)
  proc do
    handle = tracker[0]
    if handle && !handle.null?
      Bindings.pdf_document_free(handle)
      tracker[0] = nil
    end
  end
end

.open(source, password: nil) {|PdfDocument| ... } ⇒ PdfDocument, Object

Open a PDF from disk or in-memory bytes.

Parameters:

  • source (String)

    either a filesystem path or raw PDF bytes (auto-detected via %PDF- magic on BINARY-encoded input).

  • password (String, nil) (defaults to: nil)

    optional password for encrypted PDFs.

Yields:

Returns:

  • (PdfDocument, Object)

    the document, or the block's return value.

Raises:



43
44
45
46
47
48
49
50
51
52
# File 'lib/pdf_oxide/pdf_document.rb', line 43

def self.open(source, password: nil, &block)
  doc = new(source, password: password)
  return doc unless block_given?

  begin
    yield doc
  ensure
    doc.close
  end
end

Instance Method Details

#authenticate(password) ⇒ Boolean

Authenticate against this document's encryption.

Parameters:

  • password (String)

Returns:

  • (Boolean)

    true on success / unencrypted; false on wrong password.

Raises:



91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/pdf_oxide/pdf_document.rb', line 91

def authenticate(password)
  raise ::PdfOxide::ArgumentError, 'password cannot be nil' if password.nil?
  return true unless encrypted?

  # v0.3.55 cdylib doesn't expose a stable 3-arg unlock entry;
  # the legacy `pdf_document_unlock_with_password` is a phantom
  # (REMOVED) and `pdf_document_authenticate` only has the
  # 8-pointer placeholder shape.  Return false on encrypted docs
  # rather than crash — Java's PdfDocument#authenticate has the
  # same fail-closed contract.
  false
end

#auto_extractorAutoExtractor

Convenience accessor: get the configured AutoExtractor for this doc.

Returns:



367
368
369
# File 'lib/pdf_oxide/pdf_document.rb', line 367

def auto_extractor
  @auto_extractor ||= AutoExtractor.new(self)
end

#clear_search_indexObject

Drop the cached search index, if any, freeing its memory. #search rebuilds it lazily on next use.



246
247
248
249
250
251
# File 'lib/pdf_oxide/pdf_document.rb', line 246

def clear_search_index
  err = ::FFI::MemoryPointer.new(:int32)
  Bindings.pdf_document_clear_search_index(handle, err)
  raise_for_code(err.read_int32, 'clear_search_index')
  nil
end

#closeObject

Free the native handle. Idempotent — calling more than once is a no-op, not a crash. Safe to call from an ensure block.



373
374
375
376
377
378
379
380
381
382
# File 'lib/pdf_oxide/pdf_document.rb', line 373

def close
  return if @closed

  h = @handle
  @handle = nil
  @closed = true
  # Defuse the finalizer (was @tracker[0] == @handle).
  @tracker[0] = nil if @tracker
  Bindings.pdf_document_free(h) if h && !h.null?
end

#closed?Boolean

Returns true after #close.

Returns:

  • (Boolean)

    true after #close.



390
391
392
# File 'lib/pdf_oxide/pdf_document.rb', line 390

def closed?
  @closed
end

#encrypted?Boolean

Returns whether this PDF carries an encryption dictionary.

Returns:

  • (Boolean)

    whether this PDF carries an encryption dictionary.



123
124
125
126
127
128
# File 'lib/pdf_oxide/pdf_document.rb', line 123

def encrypted?
  # bool pdf_document_is_encrypted(const PdfDocument *handle) — no err arg.
  # The cdylib silently swallowed the extra err pointer pre-v0.3.55, so
  # encryption-detection failures were never surfaced.
  Bindings.pdf_document_is_encrypted(handle)
end

#extract_structured(page) ⇒ Hash

Extract a structured representation of a single page (#536). Returns the parsed StructuredPage JSON as a Hash: { "page_index", "page_width", "page_height", "regions" => [ { "kind", "text", "bbox", "spans", "column_index" } ] }.

Parameters:

  • page (Integer)

    0-based page index.

Returns:

  • (Hash)

    parsed structured page.



147
148
149
150
151
152
153
154
155
156
# File 'lib/pdf_oxide/pdf_document.rb', line 147

def extract_structured(page)
  validate_page_index(page)
  err = ::FFI::MemoryPointer.new(:int32)
  ptr = Bindings.pdf_document_extract_structured_to_json(handle, page, err)
  raise_for_code(err.read_int32, 'extract_structured')
  json = StringMarshaller.from_c_string(ptr) || ''

  require 'json'
  JSON.parse(json)
end

#extract_text(page_index) ⇒ String

Extract plain text from a single page.

Parameters:

  • page_index (Integer)

    0-based page index.

Returns:

  • (String)

    extracted text (empty for pages with no text layer).



133
134
135
136
137
138
139
# File 'lib/pdf_oxide/pdf_document.rb', line 133

def extract_text(page_index)
  validate_page_index(page_index)
  err = ::FFI::MemoryPointer.new(:int32)
  ptr = Bindings.pdf_document_extract_text(handle, page_index, err)
  raise_for_code(err.read_int32, 'extract_text')
  StringMarshaller.from_c_string(ptr) || ''
end

#extract_text_auto(page_index) ⇒ String

Auto-routed extraction for a single page (v0.3.51 #517). Returns native text where present, OCR'd text for scanned regions when the ocr feature is available, and gracefully falls back to native + empty/partial text when OCR is not available — never raises an "OCR unavailable" error on this path.

Parameters:

  • page_index (Integer)

    0-based.

Returns:

  • (String)

    extracted text.



165
166
167
168
169
170
171
# File 'lib/pdf_oxide/pdf_document.rb', line 165

def extract_text_auto(page_index)
  validate_page_index(page_index)
  err = ::FFI::MemoryPointer.new(:int32)
  ptr = Bindings.pdf_document_extract_text_auto(handle, page_index, err)
  raise_for_code(err.read_int32, 'extract_text_auto')
  StringMarshaller.from_c_string(ptr) || ''
end

#form_fieldsArray<Hash>

Returns AcroForm fields as an array of {name:, value:, type:, page:} hashes. v0.3.55 limitation: per-field page is -1 because pdf_oxide's form extractor doesn't yet surface per-field page placement; field is identified by name. When the cdylib build lacks the form-extract accessor, returns [] rather than raising — the simple-PDF case is "no form fields".

Returns:

  • (Array<Hash>)

    AcroForm fields as an array of {name:, value:, type:, page:} hashes. v0.3.55 limitation: per-field page is -1 because pdf_oxide's form extractor doesn't yet surface per-field page placement; field is identified by name. When the cdylib build lacks the form-extract accessor, returns [] rather than raising — the simple-PDF case is "no form fields".



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/pdf_oxide/pdf_document.rb', line 259

def form_fields
  return [] unless Bindings.respond_to?(:pdf_document_get_form_fields)

  err = ::FFI::MemoryPointer.new(:int32)
  ptr = begin
    Bindings.pdf_document_get_form_fields(handle, err)
  rescue ::ArgumentError
    # Phantom 8-pointer skeleton — graceful empty.
    return []
  end
  raise_for_code(err.read_int32, 'form_fields')
  return [] if ptr.nil? || ptr.null?

  json = StringMarshaller.from_c_string(ptr) || ''
  return [] if json.empty?

  require 'json'
  arr = JSON.parse(json)
  Array(arr).map do |f|
    {
      name: f['name'],
      value: f['value'],
      type: f['type'],
      page: f.fetch('page', -1)
    }
  end
rescue JSON::ParserError
  []
end

#handleFFI::Pointer

Returns raw handle for sibling classes (MarkdownConverter, AutoExtractor, PdfValidator, PdfSigner) that need to pass the pointer to their own FFI calls.

Returns:

  • (FFI::Pointer)

    raw handle for sibling classes (MarkdownConverter, AutoExtractor, PdfValidator, PdfSigner) that need to pass the pointer to their own FFI calls.

Raises:



82
83
84
85
86
# File 'lib/pdf_oxide/pdf_document.rb', line 82

def handle
  raise InvalidStateError, 'PdfDocument has been closed' if @closed || @handle.nil?

  @handle
end

#open?Boolean

Returns true if #close has not been called.

Returns:

  • (Boolean)

    true if #close has not been called.



385
386
387
# File 'lib/pdf_oxide/pdf_document.rb', line 385

def open?
  !@closed
end

#page(index) ⇒ PdfPage

Returns a lightweight view of the page at index. The page borrows from this document; using it after the doc closes raises InvalidStateError.

Returns:

  • (PdfPage)

    a lightweight view of the page at index. The page borrows from this document; using it after the doc closes raises InvalidStateError.



354
355
356
357
# File 'lib/pdf_oxide/pdf_document.rb', line 354

def page(index)
  validate_page_index(index)
  PdfPage.new(self, index)
end

#page_countInteger

Returns number of pages.

Returns:

  • (Integer)

    number of pages.



105
106
107
108
109
110
# File 'lib/pdf_oxide/pdf_document.rb', line 105

def page_count
  err = ::FFI::MemoryPointer.new(:int32)
  n = Bindings.pdf_document_get_page_count(handle, err)
  raise_for_code(err.read_int32, 'page_count')
  n
end

#pagesArray<PdfPage>

Returns every page in the document (eager).

Returns:

  • (Array<PdfPage>)

    every page in the document (eager).



360
361
362
363
# File 'lib/pdf_oxide/pdf_document.rb', line 360

def pages
  n = page_count
  Array.new(n) { |i| PdfPage.new(self, i) }
end

#pdf_versionString

Returns PDF version string (e.g. "1.7").

Returns:

  • (String)

    PDF version string (e.g. "1.7").



113
114
115
116
117
118
119
120
# File 'lib/pdf_oxide/pdf_document.rb', line 113

def pdf_version
  maj = ::FFI::MemoryPointer.new(:uint8)
  min = ::FFI::MemoryPointer.new(:uint8)
  Bindings.pdf_document_get_version(handle, maj, min)
  "#{maj.read_uint8}.#{min.read_uint8}"
rescue ::FFI::NotFoundError
  'unknown'
end

#prepare_searchObject

Build the search index for every page up front, instead of the lazy per-page build #search otherwise does on first use.



237
238
239
240
241
242
# File 'lib/pdf_oxide/pdf_document.rb', line 237

def prepare_search
  err = ::FFI::MemoryPointer.new(:int32)
  Bindings.pdf_document_prepare_search(handle, err)
  raise_for_code(err.read_int32, 'prepare_search')
  nil
end

#render(page_index, dpi: 150) ⇒ String

Render a single page to PNG bytes at the supplied DPI.

Parameters:

  • page_index (Integer)
  • dpi (Integer) (defaults to: 150)

    resolution (default 150).

Returns:

  • (String)

    PNG-encoded image bytes (BINARY).

Raises:



293
294
295
296
297
298
299
300
301
302
303
304
305
306
# File 'lib/pdf_oxide/pdf_document.rb', line 293

def render(page_index, dpi: 150)
  validate_page_index(page_index)
  err = ::FFI::MemoryPointer.new(:int32)
  img_ptr = Bindings.pdf_render_page_zoom(handle, page_index, dpi.to_f / 72.0, 0, err)
  raise_for_code(err.read_int32, 'render')
  raise InternalError, 'render returned null' if img_ptr.nil? || img_ptr.null?

  # Read length + bytes via rendered image helpers.  The cdylib
  # exposes `pdf_oxide_rendered_image_*` accessors; the simpler
  # path is the byte-buffer accessor introduced for v0.3.5x.
  bytes = read_rendered_image_bytes(img_ptr)
  Bindings.pdf_rendered_image_free(img_ptr) if Bindings.respond_to?(:pdf_rendered_image_free)
  bytes.b # binary-encoded copy (never mutates; read_* may return a frozen empty string)
end

#render_with_layers(page_index, dpi: 150, format: 0, background: [1.0, 1.0, 1.0, 1.0], transparent: false, render_annotations: true, jpeg_quality: 90, excluded_layers: []) ⇒ String

Render a single page with the full RenderOptions surface plus Optional-Content-Group (OCG) layer filtering.

Parameters:

  • page_index (Integer)
  • dpi (Integer) (defaults to: 150)

    resolution (default 150).

  • format (Integer) (defaults to: 0)

    0 = PNG, 1 = JPEG.

  • background (Array(Float,Float,Float,Float)) (defaults to: [1.0, 1.0, 1.0, 1.0])

    RGBA, each 0.0..1.0.

  • transparent (Boolean) (defaults to: false)

    drop the background fill entirely.

  • render_annotations (Boolean) (defaults to: true)

    paint annotation appearances.

  • jpeg_quality (Integer) (defaults to: 90)

    1..100 (only used when format == 1).

  • excluded_layers (Array<String>) (defaults to: [])

    OCG /Names to suppress.

Returns:

  • (String)

    encoded image bytes (BINARY).

Raises:



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
# File 'lib/pdf_oxide/pdf_document.rb', line 320

def render_with_layers(page_index, dpi: 150, format: 0,
                       background: [1.0, 1.0, 1.0, 1.0], transparent: false,
                       render_annotations: true, jpeg_quality: 90,
                       excluded_layers: [])
  validate_page_index(page_index)
  bg_r, bg_g, bg_b, bg_a = background
  names = Array(excluded_layers).map(&:to_s)

  # Build a NULL-terminated-string array (char *const *).
  names_ptr = ::FFI::Pointer::NULL
  unless names.empty?
    str_ptrs = names.map { |n| ::FFI::MemoryPointer.from_string(n) }
    names_ptr = ::FFI::MemoryPointer.new(:pointer, str_ptrs.length)
    names_ptr.write_array_of_pointer(str_ptrs)
  end

  err = ::FFI::MemoryPointer.new(:int32)
  img_ptr = Bindings.pdf_render_page_with_options_ex(
    handle, page_index, dpi, format,
    bg_r.to_f, bg_g.to_f, bg_b.to_f, bg_a.to_f,
    transparent ? 1 : 0, render_annotations ? 1 : 0, jpeg_quality,
    names_ptr, names.length, err
  )
  raise_for_code(err.read_int32, 'render_with_layers')
  raise InternalError, 'render_with_layers returned null' if img_ptr.nil? || img_ptr.null?

  bytes = read_rendered_image_bytes(img_ptr)
  Bindings.pdf_rendered_image_free(img_ptr) if Bindings.respond_to?(:pdf_rendered_image_free)
  bytes.b # binary-encoded copy (never mutates; read_* may return a frozen empty string)
end

#search(query, case_sensitive: false, regex: false) ⇒ Array<Hash>

Search this document.

Parameters:

  • query (String)

    literal text (or regex when regex: true).

  • case_sensitive (Boolean) (defaults to: false)
  • regex (Boolean) (defaults to: false)

    interpret query as a regex.

Returns:

  • (Array<Hash>)

    each match has keys :page, :text, :bbox (where :bbox is a Hash with :x, :y, :width, :height).

Raises:



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/pdf_oxide/pdf_document.rb', line 219

def search(query, case_sensitive: false, regex: false)
  raise ::PdfOxide::ArgumentError, 'query cannot be nil' if query.nil?
  raise UnsupportedFeatureError, 'regex search not supported by this cdylib build' \
    if regex && !Bindings.respond_to?(:pdf_document_search_regex)

  err = ::FFI::MemoryPointer.new(:int32)
  query_utf8 = StringMarshaller.to_utf8(query)
  results = if regex
              Bindings.pdf_document_search_regex(handle, query_utf8, case_sensitive, err)
            else
              Bindings.pdf_document_search_all(handle, query_utf8, case_sensitive, err)
            end
  raise_for_code(err.read_int32, 'search')
  parse_search_results(results)
end

#structured_warningsString

This document's structured diagnostics, as a raw JSON array string ("[]" when there are none). Non-destructive: a later call returns the same entries plus any raised since.

Left unparsed on purpose: each entry's category is an open-ended snake_case token and new ones ship in minor releases, so callers must tolerate tokens they do not know.

Returns:

  • (String)

    JSON array.



181
182
183
184
185
186
# File 'lib/pdf_oxide/pdf_document.rb', line 181

def structured_warnings
  err = ::FFI::MemoryPointer.new(:int32)
  ptr = Bindings.pdf_document_structured_warnings(handle, err)
  raise_for_code(err.read_int32, 'structured_warnings')
  StringMarshaller.from_c_string(ptr) || ''
end

#take_structured_warningsString

As #structured_warnings, but drains: the returned entries are removed, so a batch pipeline can read per document without the sink growing across the run.

Returns:

  • (String)

    JSON array.



192
193
194
195
196
197
# File 'lib/pdf_oxide/pdf_document.rb', line 192

def take_structured_warnings
  err = ::FFI::MemoryPointer.new(:int32)
  ptr = Bindings.pdf_document_take_structured_warnings(handle, err)
  raise_for_code(err.read_int32, 'take_structured_warnings')
  StringMarshaller.from_c_string(ptr) || ''
end

#to_html(page_index = nil) ⇒ String

Convert one page to HTML.

Parameters:

  • page_index (Integer) (defaults to: nil)

Returns:

  • (String)

    HTML.



209
210
211
# File 'lib/pdf_oxide/pdf_document.rb', line 209

def to_html(page_index = nil)
  page_index.nil? ? MarkdownConverter.to_html(self) : MarkdownConverter.to_html(self, page_index)
end

#to_markdown(page_index = nil) ⇒ String

Convert one page to Markdown.

Parameters:

  • page_index (Integer) (defaults to: nil)

Returns:

  • (String)

    Markdown.



202
203
204
# File 'lib/pdf_oxide/pdf_document.rb', line 202

def to_markdown(page_index = nil)
  page_index.nil? ? MarkdownConverter.to_markdown(self) : MarkdownConverter.to_markdown(self, page_index)
end