Module: RustPdf

Defined in:
lib/rustpdf.rb,
lib/rustpdf/native.rb,
lib/rustpdf/document.rb,
lib/rustpdf/editable_doc.rb

Overview

Idiomatic Ruby binding for the rust-pdf core over its C ABI (libpdf_ffi), using the built-in Fiddle stdlib. Covers the whole product surface: vector graphics, fonts/text, paragraphs, images, PDF/A (1b-3a), tagged/accessible output, attachments, AcroForm fields, manipulation, text extraction, encryption and digital signatures, plus feature licensing.

Defined Under Namespace

Modules: Align, Certify, Cipher, FacturxProfile, ImageAnchor, Native, Pdfa, Relationship, StampSpace, Version, VerticalAlign, VerticalAnchor Classes: Bookmark, Document, EditableDoc, Error, PageGeometry, PdfOverview, PdfRect, SignatureField, SignaturePolicy, SigningOptions, SigningSession, TextHit

Class Method Summary collapse

Class Method Details

.activate_license(token) ⇒ Object

Activate a license token (unlocks PDF/A, signing, encryption, accessibility). Tokens may also be supplied via the RUSTPDF_LICENSE / RUSTPDF_LICENSE_FILE environment variables (auto-activated).



263
264
265
# File 'lib/rustpdf.rb', line 263

def activate_license(token)
  check(Native.call("pdf_activate_license", token))
end

.add_dss(pdf, certs: [], crls: []) ⇒ Object

Append a Document Security Store (/DSS, PAdES-B-LT).



386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
# File 'lib/rustpdf.rb', line 386

def add_dss(pdf, certs: [], crls: [])
  cptrs = certs.map { |c| Fiddle::Pointer[c] }
  rptrs = crls.map { |c| Fiddle::Pointer[c] }
  cptr_buf = cptrs.map(&:to_i).pack("J*")
  clen_buf = certs.map(&:bytesize).pack("J*")
  rptr_buf = rptrs.map(&:to_i).pack("J*")
  rlen_buf = crls.map(&:bytesize).pack("J*")
  result = take_bytes do |pp, pn|
    Native.call("pdf_add_dss", pdf, pdf.bytesize, cptr_buf, clen_buf, certs.size,
                rptr_buf, rlen_buf, crls.size, pp, pn)
  end
  # keep the per-item pointers alive until the call has returned
  cptrs.clear
  rptrs.clear
  result
end

.begin_signing(pdf, options: nil) ⇒ Object

Model B — two-phase signing, phase 1. Prepare pdf for deferred signing: returns a SigningSession whose #hash you send to a remote HSM. Build the CMS container, then call SigningSession#complete (or .complete_signature). The key never reaches this library.



461
462
463
464
465
466
467
468
469
470
# File 'lib/rustpdf.rb', line 461

def begin_signing(pdf, options: nil)
  opts_bytes, keep = build_signing_options(options)
  doc_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  doc_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  tbs_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  tbs_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  check(Native.call("pdf_sign_begin", pdf, pdf.bytesize, opts_bytes, doc_p, doc_n, tbs_p, tbs_n))
  keep.clear
  SigningSession.new(read_buffer(doc_p, doc_n), read_buffer(tbs_p, tbs_n))
end

.begin_timestamp(pdf) ⇒ Object

Phase 1 of a network-TSA document timestamp (/DocTimeStamp). Prepares pdf and returns [document_bytes, tbs_bytes]: tbs_bytes are the bytes whose SHA-256 forms the RFC 3161 message imprint. Build a TimeStampReq with #timestamp_request, POST it to the TSA, extract the token with #timestamp_token_from_response, then embed it via #complete_signature.



487
488
489
490
491
492
493
494
# File 'lib/rustpdf.rb', line 487

def begin_timestamp(pdf)
  doc_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  doc_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  tbs_p = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  tbs_n = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  check(Native.call("pdf_timestamp_begin", pdf, pdf.bytesize, doc_p, doc_n, tbs_p, tbs_n))
  [read_buffer(doc_p, doc_n), read_buffer(tbs_p, tbs_n)]
end

.build_signing_options(options) ⇒ Object

Marshal a SigningOptions (or nil) into the C PdfSigningOptions struct bytes, returning [packed_struct, keepalive] where keepalive holds the Fiddle pointers backing the struct's string/byte fields (keep it referenced until the native call returns). 64-bit layout: 3 ptr, 2 int (8 bytes together), size_t, ptr, ptr, size_t, ptr, ptr, then the visible-signature tail: int (+ 4 pad), size_t, double, ptr, ptr, size_t.



541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
# File 'lib/rustpdf.rb', line 541

def build_signing_options(options)
  keep = []
  cstr = lambda do |s|
    return 0 if s.nil?

    p = Fiddle::Pointer[s.to_s]
    keep << p
    p.to_i
  end

  reason   = cstr.call(options&.reason)
  location = cstr.call(options&.location)
  name     = cstr.call(options&.name)
  pades    = options&.pades ? 1 : 0
  cert     = (options&.certify || Certify::NONE).to_i
  est      = (options&.container_size || 0).to_i

  policy_oid = 0
  policy_hash = 0
  policy_hash_len = 0
  policy_alg = 0
  policy_uri = 0
  if (pol = options&.policy)
    policy_oid = cstr.call(pol.oid)
    if pol.hash && !pol.hash.empty?
      hp = Fiddle::Pointer[pol.hash]
      keep << hp
      policy_hash = hp.to_i
      policy_hash_len = pol.hash.bytesize
    end
    policy_alg = cstr.call(pol.hash_algorithm_oid)
    policy_uri = cstr.call(pol.uri)
  end

  visible  = options&.visible ? 1 : 0
  vis_page = (options&.visible_page || 0).to_i
  vis_rect = Array(options&.visible_rect || [0.0, 0.0, 0.0, 0.0]).map(&:to_f)[0, 4]
  vis_rect += [0.0] * (4 - vis_rect.size)
  vis_text = cstr.call(options&.visible_text)
  vis_image = 0
  vis_image_len = 0
  if (img = options&.visible_image) && !img.empty?
    ip = Fiddle::Pointer[img]
    keep << ip
    vis_image = ip.to_i
    vis_image_len = img.bytesize
  end

  bytes = [reason, location, name].pack("J3") +
          [pades, cert].pack("l2") +
          [est, policy_oid, policy_hash, policy_hash_len, policy_alg, policy_uri].pack("J6") +
          [visible].pack("l") + "\x00\x00\x00\x00".b +          # int visible + 4-byte pad
          [vis_page].pack("J") + vis_rect.pack("d4") +
          [vis_text, vis_image, vis_image_len].pack("J3")
  [bytes, keep]
end

.check(status) ⇒ Object

Raises:



615
616
617
# File 'lib/rustpdf.rb', line 615

def check(status)
  raise Error.new(last_error, status) unless status.zero?
end

.complete_signature(document, container) ⇒ Object

Model B — phase 2. Embed a complete DER CMS / PKCS#7 container into a prepared document (from #begin_signing), returning the final signed PDF.



474
475
476
477
478
# File 'lib/rustpdf.rb', line 474

def complete_signature(document, container)
  take_bytes do |pp, pn|
    Native.call("pdf_sign_complete", document, document.bytesize, container, container.bytesize, pp, pn)
  end
end

.extract_images_to_dir(pdf, dir) ⇒ Object

Extract every raster image into dir (JPEG verbatim as .jpg, others as .png; files named pageN_name.ext). Returns the number written.



282
283
284
285
286
# File 'lib/rustpdf.rb', line 282

def extract_images_to_dir(pdf, dir)
  count = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  check(Native.call("pdf_extract_images_to_dir", pdf, pdf.bytesize, dir, count))
  count[0, Native::SIZEOF_SZ].unpack1("J")
end

.extract_page_text(pdf, page_index) ⇒ Object

Extract the text of a single 0-based page_index (Unicode via ToUnicode), without building an intermediate one-page document.



275
276
277
278
# File 'lib/rustpdf.rb', line 275

def extract_page_text(pdf, page_index)
  take_bytes { |pp, pn| Native.call("pdf_extract_page_text", pdf, pdf.bytesize, page_index, pp, pn) }
    .force_encoding(Encoding::UTF_8)
end

.extract_text(pdf) ⇒ Object

Extract a document's text (Unicode via ToUnicode).



268
269
270
271
# File 'lib/rustpdf.rb', line 268

def extract_text(pdf)
  take_bytes { |pp, pn| Native.call("pdf_extract_text", pdf, pdf.bytesize, pp, pn) }
    .force_encoding(Encoding::UTF_8)
end

.find_text(pdf, query, case_sensitive: false) ⇒ Object

Find every occurrence of query in pdf, returning positional boxes. Returns an Array of TextHit (page, text, x, y, width, height) in PDF points (origin lower-left). case_sensitive defaults to false. An empty Array means no match.



306
307
308
309
310
311
312
313
314
315
# File 'lib/rustpdf.rb', line 306

def find_text(pdf, query, case_sensitive: false)
  js = take_bytes do |pp, pn|
    Native.call("pdf_find_text_json", pdf, pdf.bytesize, query, case_sensitive ? 1 : 0, pp, pn)
  end.force_encoding(Encoding::UTF_8)
  return [] if js.empty?

  JSON.parse(js).map do |h|
    TextHit.new(h["page"], h["text"], h["x"], h["y"], h["width"], h["height"])
  end
end

.inspect_pdf(pdf) ⇒ Object

Inspect pdf without mutating it: PDF version, PDF/A level (if any), encryption posture and page count. Works even on password-protected files (the encryption fields are still reported). Returns a PdfOverview. Named inspect_pdf to avoid shadowing Object#inspect.



349
350
351
352
353
354
355
# File 'lib/rustpdf.rb', line 349

def inspect_pdf(pdf)
  js = take_bytes { |pp, pn| Native.call("pdf_inspect_json", pdf, pdf.bytesize, pp, pn) }
       .force_encoding(Encoding::UTF_8)
  o = JSON.parse(js)
  PdfOverview.new(o["version"], o["pdfaLevel"], o["encrypted"] ? true : false,
                  o["encryption"], o["requiresPassword"] ? true : false, o["pageCount"])
end

.last_errorObject



610
611
612
613
# File 'lib/rustpdf.rb', line 610

def last_error
  p = Native.call("pdf_last_error_message")
  p.null? ? "unknown error" : p.to_s
end

.list_signatures(pdf) ⇒ Object

List the signature fields in pdf (detect existing signatures before signing). Returns an Array of SignatureField; an empty Array means there are no signature fields.



519
520
521
522
523
524
525
526
527
528
529
530
531
# File 'lib/rustpdf.rb', line 519

def list_signatures(pdf)
  text = take_bytes { |pp, pn| Native.call("pdf_list_signatures", pdf, pdf.bytesize, pp, pn) }
         .force_encoding(Encoding::UTF_8)
  fields = []
  text.each_line do |line|
    line = line.chomp
    tab = line.index("\t")
    next unless tab

    fields << SignatureField.new(line[(tab + 1)..-1], line[0...tab] == "1")
  end
  fields
end

.measure_page(pdf, index) ⇒ Object

Read the geometry of a single 0-based page of pdf. Raises IndexError if index is out of range.

Raises:

  • (IndexError)


338
339
340
341
342
343
# File 'lib/rustpdf.rb', line 338

def measure_page(pdf, index)
  pages = measure_pages(pdf)
  raise IndexError, "page index #{index} out of range (#{pages.size} pages)" if index.negative? || index >= pages.size

  pages[index]
end

.measure_pages(pdf) ⇒ Object

Read the geometry (size, rotation, MediaBox, CropBox) of every page in pdf, in page order, without mutating it. Returns an Array of PageGeometry. Sizes are in PDF points; +rotated_width+/+rotated_height+ swap for 90/270 pages.



320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/rustpdf.rb', line 320

def measure_pages(pdf)
  js = take_bytes { |pp, pn| Native.call("pdf_measure_pages_json", pdf, pdf.bytesize, pp, pn) }
       .force_encoding(Encoding::UTF_8)
  return [] if js.empty?

  rect = lambda do |arr|
    a = Array(arr)
    a.size == 4 ? PdfRect.new(a[0], a[1], a[2], a[3]) : PdfRect.new(0, 0, 0, 0)
  end
  JSON.parse(js).map do |g|
    PageGeometry.new(g["page"], g["width"], g["height"], g["rotation"],
                     g["rotatedWidth"], g["rotatedHeight"],
                     rect.call(g["mediaBox"]), rect.call(g["cropBox"]))
  end
end

.out_intObject

Run a producer { |out_int| status } and return the written int.



629
630
631
632
633
# File 'lib/rustpdf.rb', line 629

def out_int
  buf = Fiddle::Pointer.malloc(Native::SIZEOF_INT, Fiddle::RUBY_FREE)
  check(yield(buf))
  buf[0, Native::SIZEOF_INT].unpack1("i!")
end

.page_count(pdf) ⇒ Object

Number of pages in pdf (free — no license required).



296
297
298
299
300
# File 'lib/rustpdf.rb', line 296

def page_count(pdf)
  count = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  check(Native.call("pdf_page_count", pdf, pdf.bytesize, count))
  count[0, Native::SIZEOF_SZ].unpack1("J")
end

.read_buffer(pp, pn) ⇒ Object

Read an out-buffer (pointer-to-pointer pp, pointer-to-len pn) into a binary String, freeing the native buffer.



600
601
602
603
604
605
606
607
608
# File 'lib/rustpdf.rb', line 600

def read_buffer(pp, pn)
  len = pn[0, Native::SIZEOF_SZ].unpack1("J")
  return "".b if len.zero?

  dptr = pp.ptr
  bytes = dptr[0, len]
  Native.call("pdf_buffer_free", dptr, len)
  bytes
end

.render_page_to_png(pdf, page = 0, dpi = 150.0) ⇒ Object

Render page page (0-based) of pdf to a PNG image at dpi dots-per-inch. Page rendering is a licensed Pro feature: raises unless a license granting it is active.



291
292
293
# File 'lib/rustpdf.rb', line 291

def render_page_to_png(pdf, page = 0, dpi = 150.0)
  take_bytes { |pp, pn| Native.call("pdf_render_page_to_png", pdf, pdf.bytesize, page, dpi.to_f, pp, pn) }
end

.sign(pdf, key_der, cert_der, reason: nil, location: nil, name: nil, pades: false) ⇒ Object

Sign a PDF (PKCS#7 detached, incremental update). Requires a license.



370
371
372
373
374
375
# File 'lib/rustpdf.rb', line 370

def sign(pdf, key_der, cert_der, reason: nil, location: nil, name: nil, pades: false)
  take_bytes do |pp, pn|
    Native.call("pdf_sign", pdf, pdf.bytesize, key_der, key_der.bytesize,
                cert_der, cert_der.bytesize, reason, location, name, pades ? 1 : 0, pp, pn)
  end
end

.sign_with(pdf, cert_der, chain: [], options: nil, &block) ⇒ Object

Model A — remote signer. Sign pdf without handing this library a key: it builds the CMS signed attributes and calls the given block for the raw RSA PKCS#1 v1.5 signature (over SHA-256 of the block's argument), then assembles and embeds the CMS. cert_der is the signer certificate (DER); chain are intermediate certificates (DER), supplied independently of the key. The private key never reaches this library. Returns the signed PDF bytes.

Raises:



411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
# File 'lib/rustpdf.rb', line 411

def sign_with(pdf, cert_der, chain: [], options: nil, &block)
  raise Error, "sign_with requires a block that produces the signature" unless block

  opts_bytes, keep = build_signing_options(options)
  cptrs = chain.map { |c| Fiddle::Pointer[c] }
  cptr_buf = cptrs.map(&:to_i).pack("J*")
  clen_buf = chain.map(&:bytesize).pack("J*")

  signer_error = nil
  closure = Fiddle::Closure::BlockCaller.new(
    Fiddle::TYPE_INT,
    [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T,
     Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP]
  ) do |_ctx, data, data_len, sig_buf, sig_cap, sig_len|
    begin
      sig = block.call(data[0, data_len]).to_s
      if sig.bytesize > sig_cap
        signer_error = Error.new("signature (#{sig.bytesize} bytes) exceeds buffer capacity #{sig_cap}")
        next 2
      end
      sig_buf[0, sig.bytesize] = sig
      sig_len[0, Native::SIZEOF_SZ] = [sig.bytesize].pack("J")
      0
    rescue StandardError => e
      signer_error = e
      1
    end
  end

  begin
    result = take_bytes do |pp, pn|
      Native.call("pdf_sign_with", pdf, pdf.bytesize, cert_der, cert_der.bytesize,
                  cptr_buf, clen_buf, chain.size, opts_bytes, closure, Fiddle::NULL, pp, pn)
    end
  rescue Error
    raise signer_error if signer_error

    raise
  end
  # keep the callback, struct and per-cert pointers alive until the call returned
  cptrs.clear
  keep.clear
  closure.to_i # touch to keep it referenced past the native call
  result
end

.take_bytesObject

Run an out-buffer producer { |out_ptr, out_len| status } and return the produced bytes, always freeing the native buffer.



621
622
623
624
625
626
# File 'lib/rustpdf.rb', line 621

def take_bytes
  pp = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  pn = Fiddle::Pointer.malloc(Native::SIZEOF_SZ, Fiddle::RUBY_FREE)
  check(yield(pp, pn))
  read_buffer(pp, pn)
end

.timestamp(pdf, tsa_key_der, tsa_cert_der, date: nil) ⇒ Object

Append a document timestamp (/DocTimeStamp, PAdES-B-LTA).



378
379
380
381
382
383
# File 'lib/rustpdf.rb', line 378

def timestamp(pdf, tsa_key_der, tsa_cert_der, date: nil)
  take_bytes do |pp, pn|
    Native.call("pdf_timestamp", pdf, pdf.bytesize, tsa_key_der, tsa_key_der.bytesize,
                tsa_cert_der, tsa_cert_der.bytesize, date, pp, pn)
  end
end

.timestamp_request(imprint, nonce: nil, cert_req: true) ⇒ Object

Build an RFC 3161 TimeStampReq (DER) for imprint (the SHA-256 of the bytes to timestamp, e.g. the tbs_bytes from #begin_timestamp). nonce is optional; cert_req asks the TSA to embed its certificate. Returns the request bytes to POST to the TSA.



500
501
502
503
504
505
# File 'lib/rustpdf.rb', line 500

def timestamp_request(imprint, nonce: nil, cert_req: true)
  take_bytes do |pp, pn|
    Native.call("pdf_timestamp_request", imprint, imprint.bytesize,
                nonce, nonce ? nonce.bytesize : 0, cert_req ? 1 : 0, pp, pn)
  end
end

.timestamp_token_from_response(response) ⇒ Object

Extract the TimeStampToken (a CMS ContentInfo) from a TSA's RFC 3161 TimeStampResp response bytes. The returned token is embedded via #complete_signature(document, token).



510
511
512
513
514
# File 'lib/rustpdf.rb', line 510

def timestamp_token_from_response(response)
  take_bytes do |pp, pn|
    Native.call("pdf_timestamp_token_from_response", response, response.bytesize, pp, pn)
  end
end

.verify_signatures(pdf) ⇒ Object

Validate every signature in pdf. Returns one Hash per signature with keys "field_name", "sub_filter", "signer", "covers_whole_document", "digest_valid", "signature_valid", "is_valid" and "byte_range", plus the richer certificate fields (any may be null): "issuer", "serial_number", "valid_from", "valid_to", "algorithm", "signing_time", "cert_count" and "has_timestamp". An empty array means the document is unsigned.



363
364
365
366
367
# File 'lib/rustpdf.rb', line 363

def verify_signatures(pdf)
  js = take_bytes { |pp, pn| Native.call("pdf_verify_signatures_json", pdf, pdf.bytesize, pp, pn) }
       .force_encoding(Encoding::UTF_8)
  js.empty? ? [] : JSON.parse(js)
end

.versionObject

Native library version string.



256
257
258
# File 'lib/rustpdf.rb', line 256

def version
  Native.call("pdf_version").to_s
end