Convert application-supplied HTML to PDF, add per-page HTML headers and footers, cache PDF bytes, and concatenate PDFs. ParademPdf works as a standalone Ruby gem with optional Rails adapters.
Your application supplies templates, CSS, fonts, translations, asset access, authorization, and output filenames. The gem uses Grover for conversion and CombinePDF for assembly.
- Ruby 3.2 or newer, within the limits of the installed dependencies.
- Node.js, compatible Puppeteer, and Chrome or Chromium for HTML conversion.
- Rails only if using the optional adapters.
The current Grover dependency requires Ruby below 4.1. See paradem_pdf.gemspec for dependency declarations and development for compatibility checks. The gem does not install Node packages or download a browser during rendering.
For a published release, add this to an existing project's Gemfile:
gem "paradem_pdf"Before publication, use a local checkout instead:
gem "paradem_pdf", path: "../paradem_pdf"Replace the path with the checkout's location. For a new standalone project,
run bundle init first, then add one of those entries. Install the Ruby bundle
and a compatible Puppeteer release from your project's directory:
bundle install
npm install puppeteer@24.17.0Puppeteer 24.17.0 with Node 22 is the fixture setup, not a universal version requirement. Use an existing compatible installation if your project already has one. For that Puppeteer installation, select its downloaded Chrome:
export PUPPETEER_EXECUTABLE_PATH="$(node -p 'require("puppeteer").executablePath()')"Alternatively, pass options: {executable_path: "/path/to/chrome"} to a document.
Keep Chrome's normal security controls enabled. Managed launches reject sandbox,
web-security, and certificate bypass flags, and GROVER_NO_SANDBOX=true.
See troubleshooting for deployment issues.
Save this as generate.rb and run bundle exec ruby generate.rb:
require "paradem_pdf"
html = <<~HTML
<!doctype html><html><head><link rel="icon" href="data:,"></head>
<body><h1>Invoice</h1><p>Total: $25.00</p></body></html>
HTML
document = ParademPdf::Document.new(
doc_type: "invoice",
body_html: html,
origin: "https://documents.example.test/",
locale: "en",
options: {format: "Letter"}
)
bytes = document.to_pdf
File.binwrite("invoice.pdf", bytes)to_pdf returns PDF bytes, not a filename. doc_type and locale identify
rendering/cache inputs; they do not select templates or change translation state.
Supply an explicit HTTP(S) origin with a host and no credentials, query, or fragment. Choose your actual asset origin when the HTML references assets. Origin validation is not HTML sanitization or a network allowlist. HTML and callbacks must be trusted application inputs. Request failures must raise.
Add a callback returning a complete HTML document. It receives one-based page
and the actual total_pages from the rendered body:
document = ParademPdf::Document.new(
doc_type: "invoice", body_html: html,
origin: "https://documents.example.test/", locale: "en",
footer: ->(page:, total_pages:) {
<<~HTML
<!doctype html><html><head><link rel="icon" href="data:,"></head>
<body><div style="position:fixed;bottom:0">#{page} / #{total_pages}</div></body></html>
HTML
},
options: {format: "Letter"},
body_margins: {bottom: "23mm"},
footer_margins: {bottom: "15mm"}
)
File.binwrite("invoice-with-footer.pdf", document.to_pdf)Headers use the same callback contract. Both default to nil. Each decoration needs its own CSS and fonts and must produce exactly one page matching the body geometry and rotation. Reserve space with body margins; the gem does not measure decorations or resize margins. Conflicting effective HTML metadata controls raise.
By default, the gem waits for page load when no effective wait is set, decodes
images, and waits for fonts. Resource failures raise. The separate resource
deadline defaults to 20 seconds; set readiness_timeout: in milliseconds to
change it, or readiness: false to opt out of the added readiness policy.
A Grover execute_script replaces the generic image/font hook. Its author owns
readiness. The default does not wait for arbitrary asynchronous application
updates or force print media. See design.
Replace the deploy_version configuration, document keyword and store metadata
key with cache_version. The application chooses the version; the gem does not
assign deployment meaning to it. Stores without pruning support now return an
empty result from ParademPdf.prune instead of raising.
Configure shared defaults with ParademPdf.configure: options, origin, cache,
cache_namespace, cache_version, and expires_in. Document keywords override
these defaults. Explicit cache: nil disables caching; explicit expires_in: nil
disables expiry. Without a configured store, the optional Rails integration uses
Rails.cache; standalone Ruby defaults to no cache.
Replace freshness and assets_version with a JSON-compatible cache_key that
declares all body and decoration dependencies. Include immutable code and asset
revisions in cache_version. It defaults to nil; the application chooses its
value and meaning. Cached documents require a non-nil cache_key.
body_html accepts a String or a zero-argument callable. A completed hit returns
validated bytes without invoking the body or decoration callbacks. Build the key
and render from the same captured data. Changes in HTML metadata and dynamic
translations must be represented in the key; they cannot be discovered on a hit.
expires_in: nil keeps entries until the store removes them. Stores implementing
paradem_pdf_metadata_support? receive metadata: on writes, containing String
keys doc_type and cache_version. Ordinary stores retain the read/write API.
ParademPdf.prune(version: "release", doc_type: "invoice", dry_run: true) previews
matching entries in the configured store. Omit dry_run to delete. keys: accepts
one stored cache key or an Array. Filters combine with AND; keys match within the
list. Pruning is optional. With no store or no paradem_pdf_prune method, the
call does nothing and returns {count: 0, versions: []}. Supported stores require
at least one nonblank selector and implement
paradem_pdf_prune(version:, doc_type:, keys:, dry_run:) and return {count:, versions:}. Applications own authorization and interactive confirmation.
Supply a store supporting read(key) and write(key, bytes, expires_in:).
For example, with your application's supplied_cache_store and the HTML above:
cached_document = ParademPdf::Document.new(
doc_type: "invoice", body_html: html,
origin: "https://documents.example.test/", locale: "en",
cache: supplied_cache_store,
cache_namespace: "my-app/invoices",
cache_key: {"template" => "invoice-v1", "snapshot" => "invoice-42-revision-3"},
cache_version: "assets-v1",
expires_in: 3600
)
bytes = cached_document.to_pdfDeclare every body and callback dependency through cache_key, and immutable code
and asset dependencies through cache_version. Use JSON-compatible values with String
Hash keys. Render and compute freshness from the same data snapshot.
Completed hits skip conversion, callbacks, and browser launch. Decorations can
also reuse cached bytes. Undeclared callback changes cannot invalidate completed
hits. Authorize access before returning bytes, and use cache: nil if dependencies
cannot be declared reliably.
False or nil writes are best-effort by default. Use
cached_document.to_pdf(require_cache_write: true) to require successful writes.
Store exceptions propagate. See optimizations and the
API reference.
Strict rendering requires an effective cache store and rejects disabled caching before resolving HTML or launching a browser.
The body renders first to learn the page count. Header/footer conversion misses then run in a bounded thread pool and merge in page order. Callbacks and cache operations stay on the calling thread. One managed browser serves the render.
Set concurrency: to a positive Integer when constructing a document. The
default is processor count minus one, with a minimum of one; concurrency: 1
serializes overlay conversions. Tune for your deployment rather than assuming
more workers are faster. See optimizations.
In an initialized Rails application, supply your controller renderer and a PDF template/layout:
require "paradem_pdf/rails"
renderer = ParademPdf::RailsRenderer.new(
renderer: ApplicationController.renderer,
layout: "pdf"
)
html = renderer.render(template: "reports/body", locals: {title: "Report"})
document = ParademPdf::Rails.document(
doc_type: "report", body_html: html,
origin: "https://documents.example.test/", locale: "en",
cache: nil
)
bytes = document.to_pdfUse layout: false for a template that produces complete HTML itself. render
also accepts assigns: for instance-variable inputs. Decorations can use the
same adapter with page and total locals. The adapter does not change locale state.
When cache is omitted, both Document.new and Rails.document use the configured
store, defaulting to Rails.cache; supply all required cache inputs. Explicit
cache: nil disables caching. The gem installs no engine,
routes, migrations, or initializer. See the API reference.
Precaching calls your document's normal rendering path before a download needs it.
Configure the allowed class names during boot, without loading the classes or
querying records. For example, in config/initializers/paradem_pdf.rb:
require "paradem_pdf/rails"
ParademPdf.configure do |config|
config.origin = "https://documents.example.test/" # Replace with your asset origin.
config.cache = Rails.cache
config.cache_version = "receipt-template-v1" # Change when code or assets change.
config.precache_document_classes = %w[ReceiptPdf]
endInclude Precacheable in an application class and enumerate its inputs. Each
Symbol-keyed Hash becomes keyword arguments to a fresh instance's to_pdf:
# app/lib/receipt_pdf.rb, or another Rails-autoloaded directory
class ReceiptPdf
include ParademPdf::Rails::Precacheable
def self.precache_entries
[{locale: "en"}, {locale: "fr"}]
end
def to_pdf(locale:, browser: nil, require_cache_write: false)
ParademPdf::Rails.document(
doc_type: "receipt", locale: locale,
body_html: "<html><body>Receipt</body></html>",
cache_key: {"template" => "receipt-v1"}
).to_pdf(browser: browser, require_cache_write: require_cache_write)
end
endReplace the fixed HTML with your application's rendering. Include every content
dependency in cache_key and render from the same captured data. If rendering
uses translations, scope the locale in your wrapper; the gem does not set it.
Forward both control keywords through the same path ordinary downloads use.
Do not put browser or require_cache_write in the entry Hashes.
Run synchronously from a Rails console, runner, or an application-owned rake task:
result = ParademPdf::Rails.precache
result.succeeded # Number of successful document attempts.
result.failed # Number of failed document attempts.
# Optionally select configured types and print their logged document warnings.
ParademPdf::Rails.precache(documents: ["ReceiptPdf"], on_warning: ->(warning) { puts warning })
# Ordinary downloads use the same cache entries.
bytes = ReceiptPdf.new.to_pdf(locale: "en")Entries run serially through one shared browser with require_cache_write: true.
Configure an effective cache store whose writes report success. Document failures
are counted and later entries continue. Infrastructure failures raise
ParademPdf::Rails::Precache::InfrastructureError with sanitized diagnostics.
For asynchronous warming after a committed change, call:
ParademPdf::Rails.precache_later(reason: :content_changed)This schedules all configured types, returns without rendering on the calling
thread, and coalesces busy submissions into one follow-up pass. It is process-local
and best-effort: restarts can lose work and different processes can warm concurrently.
Normal downloads still render on a cache miss. The gem installs no event subscriber,
model callback, rake task, or deployment hook. Applications own those triggers and
may use .precache_affected_by?(event, payload) to decide when to schedule a pass.
See the precaching guide for event integration, return statuses,
and failure details. Additional types need only their class and another configured name.
Render images as HTML, then convert the HTML like any other document:
image_html = <<~HTML
<!doctype html><html><body style="margin:0">
<img src="/assets/photo.png" alt="Photo" style="max-width:100%;max-height:90vh">
</body></html>
HTML
image_bytes = ParademPdf::Document.new(
doc_type: "image", body_html: image_html,
origin: "https://documents.example.test/", locale: "en",
options: {format: "Letter", landscape: true}
).to_pdfReplace the example URL and origin with accessible assets. Grover resolves slash-prefixed asset URLs against the origin. Your application escapes dynamic HTML values, chooses sizing and pagination, and loads attachments. The gem does not integrate with storage records.
Given PDF bytes from rendering or existing files:
attachment_bytes = File.binread("attachment.pdf")
combined_bytes = ParademPdf::Document.merge([bytes, image_bytes, attachment_bytes])
File.binwrite("combined.pdf", combined_bytes)Input order and page geometry are preserved. Empty lists and invalid PDFs raise
ParademPdf::InvalidPdf. Concatenation does not renumber existing page labels.
See the error reference.
No Rails application or database is needed. You can render HTML with your own template system, or read a complete HTML file:
require "paradem_pdf"
document = ParademPdf::Document.new(
doc_type: "report", body_html: File.read("report.html"),
origin: "https://documents.example.test/", locale: "en"
)
File.binwrite("report.pdf", document.to_pdf)Share one browser across existing document instances:
ParademPdf::Document.browser do |browser|
File.binwrite("report.pdf", document.to_pdf(browser: browser))
File.binwrite("invoice.pdf", cached_document.to_pdf(browser: browser))
endThe block closes its browser even on error. Individual to_pdf calls never close
the supplied browser. This is browser reuse, not parallel document scheduling.
The batch opens a browser even if every document is cached. Use ordinary to_pdf
for individual completed hits when no browser is needed. Batch launch options go
on Document.browser(options: ...). See the API reference.
Run these commands from the gem checkout's root directory. Install the committed Ruby bundle if needed, then run the Minitest suite:
BUNDLE_FROZEN=true bundle install
BUNDLE_FROZEN=true bundle exec rake testTo run one test file:
BUNDLE_FROZEN=true bundle exec rake test TEST='test/bootstrap_test.rb'Real-browser tests are skipped unless explicitly enabled. They require Node.js,
Puppeteer, Chrome/Chromium, and Poppler. Both the font and Chrome paths must be
supplied. For macOS with Google Chrome installed in /Applications, run:
env -u GROVER_NO_SANDBOX -u NODE_OPTIONS \
PARADEM_PDF_TEST_FONT='/System/Library/Fonts/Supplemental/Arial.ttf' \
PUPPETEER_EXECUTABLE_PATH='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' \
PARADEM_PDF_BROWSER=1 BUNDLE_FROZEN=true bundle exec rake test TEST='test/browser_test.rb'Use your platform's font and browser paths on other systems. See browser checks for installation and Linux examples. When enabled, missing prerequisites fail rather than skip. The command unsets inherited sandbox bypass settings and Node preloads for this run only; the browser fixture rejects them.
To check Ruby formatting:
BUNDLE_FROZEN=true bundle exec standardrbSee development for separate Rails compatibility checks.
- Architecture
- Design
- Optimizations
- Development
- Browser checks
- API reference
- Precaching
- Troubleshooting
These guides link to implementation and tests rather than copying internal code. Source/test links refer to the repository; the gem packages the usage and technical guides, not its test fixtures.
MIT. See LICENSE.