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. Free and open source (MIT).

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

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

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



378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
# File 'lib/rustpdf.rb', line 378

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.



453
454
455
456
457
458
459
460
461
462
# File 'lib/rustpdf.rb', line 453

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.



479
480
481
482
483
484
485
486
# File 'lib/rustpdf.rb', line 479

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.



533
534
535
536
537
538
539
540
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
# File 'lib/rustpdf.rb', line 533

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:



607
608
609
# File 'lib/rustpdf.rb', line 607

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.



466
467
468
469
470
# File 'lib/rustpdf.rb', line 466

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.



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

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.



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

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



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

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.



298
299
300
301
302
303
304
305
306
307
# File 'lib/rustpdf.rb', line 298

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.



341
342
343
344
345
346
347
# File 'lib/rustpdf.rb', line 341

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_error ⇒ Object



602
603
604
605
# File 'lib/rustpdf.rb', line 602

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.



511
512
513
514
515
516
517
518
519
520
521
522
523
# File 'lib/rustpdf.rb', line 511

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)


330
331
332
333
334
335
# File 'lib/rustpdf.rb', line 330

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.



312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/rustpdf.rb', line 312

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_int ⇒ Object

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



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

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



288
289
290
291
292
# File 'lib/rustpdf.rb', line 288

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.



592
593
594
595
596
597
598
599
600
# File 'lib/rustpdf.rb', line 592

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.



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

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



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

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:



403
404
405
406
407
408
409
410
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
# File 'lib/rustpdf.rb', line 403

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_bytes ⇒ Object

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



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

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



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

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.



492
493
494
495
496
497
# File 'lib/rustpdf.rb', line 492

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



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

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.



355
356
357
358
359
# File 'lib/rustpdf.rb', line 355

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

.version ⇒ Object

Native library version string.



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

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