Project

bidi2pdf

0.01
The project is in a healthy, maintained state
Bidi2pdf is a powerful PDF generation tool that uses Chrome's BiDirectional Protocol to render web pages as high-quality PDF documents. It offers: * Command-line interface for easy PDF generation * Support for cookies, headers, and basic authentication * Waiting conditions (window loaded, network idle) * Headless Chrome operation for server environments * Docker compatibility * Customizable PDF output options Bidi2pdf uses ChromeDriver to control Chrome through its BiDi protocol, providing precise rendering for reports, invoices, documentation, and other PDF documents from web-based content. It automatically manages the ChromeDriver binary and browser sessions for a seamless experience.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

>= 2.4, < 4.0
~> 1.3
>= 0.2, < 0.4
~> 1.0, >= 1.3.1
>= 2.10, < 4.0
~> 1.2
 Project Readme

Build Status Maintainability Gem Version Open Source Helpers


πŸ“„ Bidi2pdf – Bulletproof PDF generation via Chrome's BiDi Protocol

Bidi2pdf is a powerful Ruby gem that transforms modern web pages into high-fidelity PDFs using Chrome’s BiDirectional (BiDi) protocol. Whether you're automating reports, archiving websites, or shipping documentation, Bidi2pdf gives you precision, flexibility, and full control.

For agents and automation: the use cases on one page - dieter-medium.github.io/bidi2pdf, and as plain text for language models: llms.txt. Both are published from site/ by .github/workflows/pages.yml.


πŸ“š Table of Contents

  1. Key Features
  2. Quick Start
  3. Why BiDi?
  4. Installation
  5. CLI Usage
  6. Agent and Automation Usage
  7. Library API
  8. Architecture
  9. Docker
  10. Configuration Options
  11. Programmatic Configuration
  12. Rails Integration
  13. Test Helpers
  14. Development
  15. Contributing
  16. License

✨ Key Features

βœ… One-liner CLI – From URL to PDF in a single command
βœ… Full customization – Inject cookies, headers, auth credentials
βœ… Smart waiting – Wait for complete page load or network idle
βœ… Headless support – Run quietly in the background
βœ… Docker-ready – Plug and play with containers
βœ… Modern architecture – Built on Chrome's next-gen BiDi protocol
βœ… Network logging – Know which requests fail during rendering
βœ… Console log capture – See what goes wrong inside the browser
βœ… Agent-ready – Structured JSON output, NDJSON progress streaming, a page diagnostic, and declarative recipes, so LLM agents and CI can drive it without parsing logs


⚑ Quick Start

Get up and running in three easy steps:

# 1. Install the gem (system-wide)
gem install bidi2pdf

# 2. Render any page to PDF
bidi2pdf render --url https://example.com --output example.pdf

# 3. Open the PDF (macOS shown; use xdg-open on Linux)
open example.pdf

Bundler users – Add it to your project with bundle add bidi2pdf.


πŸš€ Installation

Bundler

gem 'bidi2pdf'

Standalone

gem install bidi2pdf

Requirements


βš™οΈ Basic Usage

Command-line

bidi2pdf render --url https://example.com/invoice/14432423 --output example.pdf

Advanced CLI Options

bidi2pdf render \
  --url https://example.com/invoice/14432423 \
  --output example.pdf \
  --cookie session=abc123 \
  --header X-API-KEY=token \
  --auth admin:password \
  --wait_network_idle \
  --wait_window_loaded \
  --log-level debug

The page as it looks on screen, dark mode, or its mobile version

By default Chrome prints with the page's print stylesheet. --media screen prints its screen styles instead, --color_scheme dark its dark mode, --reduced_motion reduce with animations off.

A narrow page adds the page's mobile layout. A printed page is laid out at the width between its side margins, not at the paper's width: page width minus --margin_left and --margin_right, at about 37.8 CSS px per cm (96 / 2.54). Without margins of your own Chrome uses 1 cm a side, so a 9.5 cm page lays out at about 283 px. For a 360 px phone layout, set the side margins explicitly:

bidi2pdf render --url https://example.com --output mobile.pdf \
  --media screen --color_scheme dark \
  --page_width 9.525 --page_height 20 --margin_left 0 --margin_right 0

8.467 cm without side margins gives 320 px. All page sizes and margins are in centimetres.

bidi2pdf diagnose takes the same three options, so its screenshot shows the same emulation.


πŸ€– Agent and Automation Usage

Every command below also works without --json (human-readable output on stdout); with it, stdout carries exactly one JSON document and nothing else - safe to pipe into jq or parse directly. Human-readable logs move to stderr for the duration of any --json/--output - call, never mixing into stdout. --json-stream works independently of the two: it always writes progress events to stderr, whether or not --json is also given - in human mode, that just means human-readable output keeps going to stdout as usual, alongside the stream. Full JSON Schema for every shape is built into the gem, so an agent can discover it without reading this file:

# 1. Check compatibility first - no browser launched
bidi2pdf version --json

# 2. Discover the shape of each command's own result, a manifest, an NDJSON event, or a recipe file
bidi2pdf schema render
bidi2pdf schema diagnose
bidi2pdf schema run
bidi2pdf schema manifest
bidi2pdf schema event
bidi2pdf schema recipe

# 3. Validate a recipe - no browser launched, the cheapest way to iterate on one
bidi2pdf run recipe.yml --validate

# 4. Run it for real
bidi2pdf run recipe.yml --json

Structured render output

bidi2pdf render --url https://example.com/invoice/14432423 --output example.pdf --json
{
  "schema_version": 1,
  "ok": true,
  "command": "render",
  "output": "example.pdf",
  "bytes": 182734,
  "sha256": "abcd...",
  "pages": 2,
  "duration_ms": 842,
  "navigation": { "requested_url": "https://example.com/invoice/14432423", "final_url": "https://example.com/invoice/14432423", "status": 200 },
  "console": [],
  "network_failures": [],
  "warnings": [],
  "error": null
}

pages is null, with a warning explaining why, when the optional pdf-reader gem isn't installed - see Docker for why the published images always have it. A failed render still emits exactly this shape, with ok: false and a structured error (code, message, retryable, hint, details) instead of a stack trace:

Exit code Meaning
0 success
2 CLI, configuration, or recipe-validation error
3 browser or navigation error
4 page not as expected (a recipe action/assertion, or a diagnose selector, failed)
6 output or PDF generation failure
70 unexpected internal error

stdin/stdout, manifests, and progress streaming

# Render HTML piped on stdin, PDF bytes piped out on stdout - no input or output file of your own
# (the PDF goes straight to stdout; the HTML is kept in a temporary file while Chrome loads it, then deleted)
cat page.html | bidi2pdf render --stdin --output - > page.pdf

# A render manifest: enough to reproduce and diagnose the render later
bidi2pdf render --url https://example.com --output example.pdf --manifest render.json

# One JSON progress event per stderr line as the render happens - works with human-readable
# output too, not just --json
bidi2pdf render --url https://example.com --output example.pdf --json-stream

Diagnosing a page before trusting its PDF

bidi2pdf diagnose loads a page like render does but produces no PDF - it answers why does the PDF not look like the page (console errors, failed requests, font-loading status, @media print/@page rules, fixed/sticky elements, Paged.js detection), not what the page's content is:

bidi2pdf diagnose --url https://example.com/invoice/14432423 --json

Declarative recipes

A recipe is a rendering contract - the waits a page needs before it's printed, and the properties the resulting PDF must have - not a general browser-automation script:

# invoice.yml
version: 1

source:
  url: https://example.com/invoice/123

actions:
  - wait_for:
      selector: "#invoice"
      timeout: 10
  - click:
      selector: "#show-details"
  - wait_network_idle:
      timeout: 10

assert:
  - selector_exists:
      selector: "#total"
  - no_console_errors: true
  - page_count: 2
  - pdf_text_present:
      text: "Invoice #123"

output:
  pdf: invoice.pdf
  manifest: invoice.json
bidi2pdf run invoice.yml --validate   # schema + known actions/assertions, no browser
bidi2pdf run invoice.yml --json       # actions, then the PDF, then assertions against it

Actions (wait_for, click, evaluate, inject_script, inject_style, set_viewport, wait_network_idle, emulate_media) and page assertions (selector_exists, text_present, no_console_errors, no_network_failures, fonts_loaded) are a thin layer over the same BrowserTab methods the Programmatic API below uses directly. PDF assertions (page_count, pdf_text_present, pdf_not_blank) need the pdf-reader gem - a recipe using one fails --validate immediately, before any browser launches, when it isn't installed.

no_console_errors, no_network_failures, fonts_loaded, and pdf_not_blank are presence-only assertions - the step is either there or it isn't, nothing reads the value beside it - so they must be written as true exactly, e.g. - no_console_errors: true; false (or any other value) is rejected by both bidi2pdf schema recipe and --validate rather than being silently ignored. wait_for needs exactly one of selector, paged_js, script - zero or more than one is rejected the same way, before any browser launches.

Claude Code skills

The repository ships a Claude Code plugin with six skills, so an agent uses bidi2pdf the way it is meant to be used instead of guessing options:

Skill For
render-pdf a URL or HTML file to PDF with the CLI: a preflight that suggests how to get Ruby and a browser, order of commands, waits, screen/dark/mobile view, reading the JSON result and exit codes
pdf-recipe a recipe for pages that need steps before printing, validated without a browser
diagnose-pdf a blank, unstyled or wrongly paged PDF: a checklist around bidi2pdf diagnose
remote-chrome a shared chromedriver container: settings, sizing, session cleanup, untrusted HTML
page-screenshots review screenshots per page at phone and desktop width, light and dark (with a script)
pdf-specs RSpec specs with the gem's PDF matchers and Chrome container helpers

Every skill starts with short ground rules: bidi2pdf is the only renderer (a failure is reported, not worked around with another PDF engine), nothing is installed without the user's go-ahead, and bidi2pdf's own JSON result comes before other PDF tools. render-pdf's preflight has the setup details.

Installing the skills

The plugin is not in a central store: this repository is its own Claude Code marketplace (.claude-plugin/marketplace.json, marketplace name bidi2pdf). You add the repository as a marketplace once, then install the plugin from it. In a Claude Code session:

/plugin marketplace add dieter-medium/bidi2pdf
/plugin install bidi2pdf@bidi2pdf

/plugin install does not install right away: it opens the plugin's details, where you choose the scope (just you, the project, or only this checkout) and confirm the installation.

Or from a shell, where the install happens at once:

claude plugin marketplace add dieter-medium/bidi2pdf
claude plugin install bidi2pdf@bidi2pdf          # --scope user (default), project or local

The skills then load on their own when a task fits them; nothing else needs configuring.

Which version you get. The plugin's version is the gem's (claude-plugin/.claude-plugin/plugin.json), and Claude Code installs an update only when that version changes - not with every commit. Adding the marketplace as above follows the default branch, so after a release the skills there can already describe the next, unreleased gem (the version then reads e.g. 0.1.24-pre). To stay on the skills of the gem you actually use, add the marketplace at its release tag:

claude plugin marketplace add dieter-medium/bidi2pdf#v0.1.23

Updating and removing. Auto-update is off for this marketplace unless you turn it on (/plugin > Marketplaces). By hand:

claude plugin marketplace update bidi2pdf     # fetch the marketplace again
claude plugin update bidi2pdf@bidi2pdf        # take the new version
claude plugin uninstall bidi2pdf@bidi2pdf     # remove the plugin only
claude plugin marketplace remove bidi2pdf     # remove the marketplace and every plugin installed from it

For a whole team. To give everyone working in a project the skills, commit this to the project's .claude/settings.json - each member gets them after accepting Claude Code's trust prompt for the folder:

{
  "extraKnownMarketplaces": {
    "bidi2pdf": { "source": { "source": "github", "repo": "dieter-medium/bidi2pdf" } }
  },
  "enabledPlugins": { "bidi2pdf@bidi2pdf": true }
}

claude plugin marketplace add dieter-medium/bidi2pdf --scope project and claude plugin install bidi2pdf@bidi2pdf --scope project write the same entries for you.

From a checkout, without installing (to try a change to a skill):

claude --plugin-dir ./claude-plugin                   # for this one session
claude plugin validate ./claude-plugin --strict       # check the plugin's structure

The skills point at bidi2pdf help <command> and bidi2pdf schema <kind> for exact options and shapes; a spec (spec/unit/claude_plugin_spec.rb) checks every command, option, error code, recipe and method they name against the gem, so they cannot drift from it. The plugin is not part of the gem itself.

In Codex

The same six skills install in OpenAI's Codex. Codex reads the plugin folder through its portable manifest (claude-plugin/plugin.json, Agent Plugins 1.0.0, listing under extensions.com.openai) and the repository's own marketplace, .agents/plugins/marketplace.json:

codex plugin marketplace add dieter-medium/bidi2pdf              # follows the default branch
codex plugin marketplace add dieter-medium/bidi2pdf@v<version>   # pinned to a release tag (or a commit SHA)

Then install bidi2pdf from that marketplace in Codex's plugin browser (/plugins in the CLI, or the Plugins Directory in the desktop app) and start a new session. The marketplace entry offers the plugin for Codex only ("products": ["CODEX"], as in OpenAI's own marketplace): the skills run bidi2pdf in a shell, which ChatGPT does not have. codex plugin marketplace upgrade bidi2pdf fetches a newer state of the marketplace.


🧠 Programmatic API

Classic Approach

require 'bidi2pdf'

launcher = Bidi2pdf::Launcher.new(
  url: 'https://example.com/invoice/14432423',
  output: 'example.pdf',
  cookies: { 'session' => 'abc123' },
  headers: { 'X-API-KEY' => 'token' },
  auth: { username: 'admin', password: 'password' },
  wait_window_loaded: true,
  wait_network_idle: true
)

launcher.launch

DSL – Quick & Clean

require "bidi2pdf"

Bidi2pdf::DSL.with_tab(headless: true) do |tab|
  tab.navigate_to("https://example.com/invoice/14432423")
  tab.wait_until_network_idle
  tab.print("example.pdf")
end

🧬 Deep Integration Example

Get fine-grained control using Chrome sessions, tabs, and BiDi commands:

πŸ” Show full example
require "bidi2pdf"

# 1. Remote or local session?
session = Bidi2pdf::Bidi::Session.new(
  session_url: "http://localhost:9092/session",
  headless: true,
)

# Alternative: local session via ChromeDriver
# manager = Bidi2pdf::ChromedriverManager.new(headless: false)
# manager.start
# session = manager.session

session.start
session.client.on_close { puts "WebSocket session closed" }

# 2. Create browser/tab
browser = session.browser
context = browser.create_user_context
window = context.create_browser_window
tab = window.create_browser_tab

# 3. Inject configuration
tab.set_cookie(name: "auth", value: "secret", domain: "example.com", secure: true)
tab.add_headers(url_patterns: [{ type: "pattern", protocol: "https", hostname: "example.com", port: "443" }],
                headers: [{ name: "X-API-KEY", value: "12345678" }])
tab.basic_auth(url_patterns: [{ type: "pattern", protocol: "https", hostname: "example.com", port: "443" }],
               username: "username", password: "secret")

# 4. Render PDF
tab.navigate_to "https://example.com/invoice/14432423"

# Alternative: send html code to the browser
# tab.render_html_content("<html>...</html>")

# Inject JavaScript if, needed
# as an url
# tab.inject_script "https://example.com/script.js" 
# or inline
# tab.inject_script "console.log('Hello from injected script!')"

# Inject CSS if needed
# as an url
# tab.inject_style url: "https://example.com/simple.css"
# or inline
# tab.inject_style content: "body { background-color: red; }"

# Printing HTML you do not trust? Switch its scripts off before navigating:
# tab.disable_script_execution

# Print the screen styles in dark mode instead of the print stylesheet (holds for later
# navigations in this tab; tab.emulate_media without arguments ends it):
# tab.emulate_media(media: "screen", color_scheme: "dark")

tab.wait_until_network_idle
tab.print("my.pdf")

# 5. Cleanup
tab.close
window.close
context.close
session.close

🌐 Architecture

%%{  init: {
      "theme": "base",
      "themeVariables": {
        "primaryColor":  "#E0E7FF",
        "secondaryColor":"#FEF9C3",
        "edgeLabelBackground":"#FFFFFF",
        "fontSize":"14px",
        "nodeBorderRadius":"6"
      }
    }
}%%
flowchart LR
%% ----- Ruby side ---------
    A["fa:fa-gem Ruby Application"]
    B["fa:fa-gem bidi2pdf<br/>Library"]
%% ----Chrome environment -----------
    subgraph C["fa:fa-chrome Chrome Environment"]
        direction TB
        C1["fa:fa-chrome Local Chrome<br/>(sub-process)"]
        C2["fa:fa-docker Docker Chrome<br/>(remote)"]
    end

    D[[PDF File]]
%% ---- Data / control flows ------
    A -- " HTML / URL + JS / CSS " --> B
    B -- " WebDriver BiDi " --> C1
    B -- " WebDriver BiDi " --> C2
    C1 -- " PDF bytes " --> B
    C2 -- " PDF bytes " --> B
    B -- " PDF " --> D
%% --- Optional extra styling classes (for future tweaks) ---
    classDef ruby fill:#E0E7FF,stroke:#6366F1,color:#1E1B4B;
    classDef chrome fill:#FEF9C3,stroke:#F59E0B,color:#78350F;
    class A,B ruby;
    class C1,C2 chrome;
Loading

🐳 Docker Support

πŸ› οΈ Build & Run Locally

# Prepare the environment
rake build

# Build the Docker image
docker build -t bidi2pdf -f docker/Dockerfile .

# Run the container and generate a PDF
docker run -it --rm \
  -v ./output:/reports \
  bidi2pdf \
  bidi2pdf render --url=https://example.com/invoice/14432423 --output /reports/example.pdf

⚑ Use the Prebuilt Image (Recommended for Fast Start)

Grab it directly from Docker Hub

docker run -it --rm \
  -v ./output:/reports \
  dieters877565/bidi2pdf:main-slim \
  bidi2pdf render --url=https://example.com/invoice/14432423 --output /reports/example.pdf

βœ… Tip: Mount your local directory (e.g. ./output) to /reports in the container to easily access the generated PDFs.

βœ… All images run with a read-only root filesystem (--read-only) as long as /tmp is writable, e.g. --tmpfs /tmp: they point Chromium's XDG_CONFIG_HOME/XDG_CACHE_HOME there, without which Chromium's crash handler fails to start and every launch aborts with chrome_crashpad_handler: --database is required.

βœ… Both published images also install pdf-reader - not a runtime dependency of the gem itself (see Agent and Automation Usage) - so pages and the page_count/pdf_text_present/pdf_not_blank recipe assertions work out of the box in either image, no extra install step needed.

ChromeDriver image tags

dieters877565/chromedriver is built for linux/amd64 and linux/arm64 with these tags:

Tag Moves? Use
0.1.17 (a release) no a fixed Chromium, pinned together with the gem version
sha-<short commit> only if that commit is built again by hand a fix on main not released yet
latest, main yes, every push to main the newest build and its Chromium security fixes

Chromium comes from Debian's packages at build time, so every build can carry a different Chromium - for a byte-exact pin use the digest (docker buildx imagetools inspect <image:tag>).

Hyphenation

The images (chromedriver, bidi2pdf and its slim variant) ship Chromium's hyphenation dictionaries in /usr/lib/chromium/hyphen-data/, so CSS hyphens: auto breaks long words in the page's language (lang="de" on the element or the document) - for exactly these languages, by the lang tag Blink maps to each dictionary (case does not matter; Blink drops trailing subtags until one matches):

lang Dictionary
de, de-DE, de-AT, de-CH (and de-DE-1901 - the region wins) German, 1996 spelling (de-1996)
de-1901 German, traditional spelling
de-CH-1901, de-LI-1901 Swiss German, traditional spelling
en, en-US US English
en-GB, en-AU, en-IE, ... (every other en-*) British English
fr, es, it, nl, pt (with any region, e.g. pt-BR) French, Spanish, Italian, Dutch, Portuguese

The hyphenation spec prints one word in each of these, against a control without hyphenation. Debian's Chromium has none, and the component it would download needs a profile that outlives the session. docker/install-hyphen-data.sh fetches them at build time from Chromium's source, pinned by the HYPHEN_DATA_REF build argument - deliberately independent of the Debian Chromium the image installs, since the pattern data and its format change far more rarely; the hyphenation spec catches a Chromium that stops reading them - with their licence notice (LICENSE next to them). Do not pass --disable-component-update in chrome_args: Chromium then skips the dictionaries.

ChromeDriver health check and watchdog

The image has a Docker HEALTHCHECK (chromedriver's /status), so docker ps shows a chromedriver that stopped answering as unhealthy. It does not see a Chrome that is wedged behind a chromedriver that still answers - memory thrashing shows only to the watchdog. And Docker does not restart a container for being unhealthy - only when its main process exits.

That is what the opt-in watchdog does. With WATCHDOG_ENABLED=true it checks every WATCHDOG_INTERVAL seconds and, after WATCHDOG_FAILURES failed checks in a row, stops chromedriver; the container exits non-zero and its restart policy starts a fresh one - any policy but no (--restart unless-stopped or on-failure, restart: in Compose; Kamal sets unless-stopped for accessories). Without one, a watchdog restart just stops the container. A check fails when chromedriver does not answer /status, or when the container's memory thrashes: Linux's pressure stall information (memory.pressure, "full") shows every task stalled on memory for at least WATCHDOG_MEMORY_PRESSURE percent of the last 10 s - a Chrome swapping at its memory limit, which answers no command but never crashes.

Renders still running on that Chrome fail when it restarts; a client with a sweeper retries them.

Variable Default Description
WATCHDOG_ENABLED false true starts the watchdog.
WATCHDOG_INTERVAL 15 Seconds between checks.
WATCHDOG_FAILURES 3 Failed checks in a row before the restart.
WATCHDOG_STATUS_TIMEOUT 5 Seconds /status may take.
WATCHDOG_MEMORY_PRESSURE 50 "full avg10" percent that fails a check; 0 turns it off.
WATCHDOG_STOP_GRACE 10 Seconds between stopping chromedriver (TERM) and killing it.
WATCHDOG_PRESSURE_FILE /sys/fs/cgroup/memory.pressure The PSI file; without one (cgroup v1, PSI off) only /status counts.

All values are whole numbers (seconds, percent). A malformed value stops the watchdog from starting - chromedriver keeps running unwatched, and the container log says why.

Picking WATCHDOG_MEMORY_PRESSURE: "full" counts only the time every task waited on memory, so it stays near 0 while renders merely run; 50 % of the last 10 s is a container that is stuck, not busy. Before lowering it, watch /sys/fs/cgroup/memory.pressure inside the container under your usual render load and stay well above what you see there.

Docker Compose

rake build
docker compose -f docker/docker-compose.yml up -d

# simple example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/sample.html --wait_window_loaded --wait_network_idle --output /reports/simple.pdf

# with a local file
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=file:///reports/sample.html--wait_network_idle --output /reports/simple.pdf


# basic auth example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/basic/sample.html --auth admin:secret --wait_window_loaded --wait_network_idle --output /reports/basic.pdf

# header example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/header/sample.html --header "X-API-KEY=secret" --wait_window_loaded --wait_network_idle --output /reports/header.pdf

# cookie example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/cookie/sample.html --cookie "auth=secret" --wait_window_loaded --wait_network_idle --output /reports/cookie.pdf

# remote chrome example
docker compose -f docker/docker-compose.yml exec app bidi2pdf render --url=http://nginx/cookie/sample.html --remote_browser_url http://remote-chrome:3000/session --cookie "auth=secret" --wait_window_loaded --wait_network_idle --output /reports/remote.pdf

docker compose -f docker/docker-compose.yml down

🧩 Configuration Options

Flag Description
--url Target URL (required)
--output Output PDF file (default: output.pdf)
--cookie Set cookie in name=value format
--header Inject custom header name=value
--auth Basic auth as user:pass
--headless Run Chrome headless (default: true)
--port ChromeDriver port (0 = auto)
--wait_window_loaded Wait until window.loaded is set to true
--wait_network_idle Wait until network is idle
--log_level Log level: debug, info, warn, error, fatal
--remote_browser_url Connect to remote Chrome session
--default_timeout Operation timeout (default: 60s)
--json Emit a single structured JSON result document (see bidi2pdf schema render)
--json_stream Emit one JSON progress event per line to stderr (see bidi2pdf schema event) - works with or without --json
--stdin Read the HTML document from stdin, instead of --url/--html-file
--output - Write raw PDF bytes to stdout instead of a file
--manifest FILE Write a render manifest (see bidi2pdf schema manifest) to FILE

See Agent and Automation Usage above for bidi2pdf diagnose, bidi2pdf run recipe.yml, bidi2pdf schema <kind>, and bidi2pdf version --json - a separate set of commands with their own options, not additional flags on render.


πŸ”§ Programmatic Configuration

Beyond the per-render CLI flags above, a few gem-wide defaults are set once via Bidi2pdf.configure:

Bidi2pdf.configure do |config|
  config.default_timeout = 60 # seconds - default BiDi command timeout
  config.enable_default_logging_subscriber = true
  config.log_truncate_limit = 200 # bytes - see below
  config.chromedriver_log_level = "WARNING" # see below
  config.session_start_concurrency = 2 # Chrome session setups at once in this process - see "Session start limit"
  config.session_start_timeout = nil # seconds a setup waits for its turn; nil: default_timeout
end
Setting Default Description
default_timeout 60 Default timeout (seconds) for BiDi commands that don't specify their own.
enable_default_logging_subscriber true Subscribes a default logger to the gem's internal instrumentation events.
log_truncate_limit 200 Max bytes kept when logging a value that can be large (e.g. a data: URL) - truncated at a byte, not character, boundary.
chromedriver_log_level nil ChromeDriver's own --log-level ("ALL"/"INFO"/"WARNING"/"SEVERE"). Unset mirrors Bidi2pdf.logger.level; set explicitly to quiet ChromeDriver's own (often very verbose) output independently of your app's log level.
session_start_concurrency 2 How many Chrome session setups (session request to first open tab) may run at once in this process; nil turns the limit off. See "Session start limit".
session_start_timeout nil Seconds a setup waits for its turn before it fails with SessionStartTimeoutError (code BROWSER_LAUNCH_FAILED, retryable); nil uses default_timeout.
session_gate nil An object of your own answering with_permit { ... } - e.g. a lock shared by several processes - instead of the per-process semaphore. See "Session start limit".

Bidi2pdf.logger, Bidi2pdf.network_events_logger, Bidi2pdf.browser_console_logger, and Bidi2pdf.notification_service are also configurable in the same block, for more advanced logging/instrumentation needs.

Session start limit

When several new Chrome sessions are set up at the same moment, chromedriver/Chrome sometimes leaves one of the setup commands unanswered (the first browsingContext.create or navigate of a fresh session): the render waits out its command timeout, and the session is left behind unanswering, which wears the Chrome down for the renders after it. Measured on chromedriver/Chrome 156 against one remote chromedriver: two setups at once stayed clean in 720 renders; three at once failed in the first round.

So bidi2pdf lets at most session_start_concurrency setups run at once in a process - from the session request to the first open tab, in every path (the CLI, Bidi2pdf::DSL.with_tab, the session warmer). Rendering is never limited: eight PDFs can render side by side while their setups took turns. A setup that cannot get a turn within session_start_timeout fails with SessionStartTimeoutError instead of queueing for ever. Every turn instruments session_gate.bidi2pdf (limit, wait in seconds, acquired).

The limit holds per process. Chrome sees the setups of every process that talks to it, so processes sharing one Chrome divide its budget: two processes with a limit of 1 each are as safe as one with 2 - two with 2 each failed in every run. With Puma workers and a job process on one remote chromedriver, set the limit so that limit x processes stays at about 2, or give the processes Chromes of their own.

It is one budget per process, whichever Chrome a setup goes to: two independent chromedrivers (a local one and a remote one, or two remote endpoints) still take turns. The limit is read once per process, at its first session setup (and again in a forked child): set it at boot, in an initializer. A later change is logged and takes effect after a restart.

A gate across processes. config.session_gate takes an object of your own instead of the per-process semaphore - anything answering with_permit { ... } - so several processes can share one budget. Every setup path (CLI, Bidi2pdf::DSL, the session warmer, bidi2pdf-rails) asks it, and session_gate.bidi2pdf is instrumented as before. Your gate has to run the block with a permit, give the permit back however the block ends, raise Bidi2pdf::SessionStartTimeoutError when none comes free in time, and not lose permits when a process dies or forks (session_start_concurrency and session_start_timeout apply only if it reads them). A sketch for processes on one host - the kernel drops a flock when its process ends:

# N lock files in a directory every process can reach: one free file is one permit.
class FileLockGate
  def initialize(dir, slots: 2, timeout: 60)
    @paths = Array.new(slots) { |index| File.join(dir, "session-start-#{index}.lock") }
    @timeout = timeout
  end

  def with_permit
    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout
    loop do
      @paths.each do |path|
        File.open(path, File::RDWR | File::CREAT, 0o600) do |file|
          return yield if file.flock(File::LOCK_EX | File::LOCK_NB) # closing the file releases the lock
        end
      end
      raise Bidi2pdf::SessionStartTimeoutError, "no session start permit free" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline

      sleep 0.05
    end
  end
end

Bidi2pdf.configure { |config| config.session_gate = FileLockGate.new("/var/lib/myapp/bidi2pdf", slots: 2) }

The evidence. bidi2pdf's own measurements come first (2026-10-28, chromedriver/Chrome 156, one remote chromedriver, the chromedriver swept between runs): at a limit of 2, 0 of 720 renders failed at 4, 6 and 8 renders at once in ten interleaved rounds; at 3, renders failed from the first round on a freshly started Chrome; ungated, 104 of 160 failed. Every unanswered command was traced as written to an open WebSocket, with no reply carrying its id ever read and no error raised - the client loses nothing. bin/session-start-repro reproduces it against any chromedriver (LIMIT=none against LIMIT=2).

Related reports. No upstream report of exactly this was found (searched 2026-10-29: chromium-bidi, ChromeDriver, Selenium, Puppeteer, WebdriverIO, Mozilla). What follows is context, not confirmation - no other project has reported this bug.

Related concurrency and lifecycle defects in WebDriver BiDi:

  • Mozilla bug 2019511 "browsingContext.setViewport can timeout if several contexts are created in parallel" - the closest analogy. In Firefox's BiDi a context's active state could change before a waiting command ran, so a command depending on it timed out under parallel context creation; fixed in Firefox 150 by always updating the dimensions synchronously in browsingContext.create. Another browser and another cause, but the same kind of race: parallel context creation leaving a command waiting.
  • ChromeDriver 127, crbug 341048786 and crbug 1503101 (crrev.com/c/5575497): a race between ChromeDriver and the BiDi mapper during session creation, fixed by waiting for browsingContext.getTree to return a non-empty list. The same phase - a session's start - but already fixed in the 156 measured here, so what bidi2pdf meets is a race still left there.
  • ChromeDriver 126, crbug 4762 (crrev.com/c/5515013), and 144, crbug 458027819 (crrev.com/c/7128058): Chrome throttling background and occluded windows in BiDi sessions. bidi2pdf already passes --disable-background-timer-throttling, --disable-renderer-backgrounding and --disable-backgrounding-occluded-windows.

The same symptom, no shown common cause:

  • puppeteer#4039 "browser.newPage freezes (never resolves)" - a new page that never comes back; no root cause was found, closed as out of scope.
  • webdriverio#16051 - pending BiDi commands waiting for their whole timeout after the connection closed, because the client never rejected them; fixed in WebdriverIO (PR #16054). A client-side cause of the same symptom, ruled out for bidi2pdf by the trace above.

Pre-warmed sessions (Bidi2pdf::SessionWarmer)

Optional. Keeps a few Chrome sessions started in the background so a render skips browser startup. Every slot is still used for exactly one render and then discarded - isolation is the same as launching a fresh Chrome per PDF. A slot is warmed together with its user context, window and tab, under one turn of the session start limit, so a checkout sets up nothing.

Bidi2pdf::SessionWarmer.configure do |c|
  # warms c.size sessions right here, e.g. at boot
  c.size = 2
  c.max_idle_age = 300
  # c.remote_browser_url = "http://remote-chrome:3000/session"
end

Bidi2pdf::SessionWarmer.with_tab do |tab|
  tab.navigate_to(url)
  tab.print("invoice.pdf")
end

Bidi2pdf::SessionWarmer.shutdown
Setting Default Description
size 1 Number of sessions kept warm. With none ready, a render starts its own session as usual - it never waits.
max_idle_age 300 Seconds a warm session may sit unused before it is retired and replaced. nil disables the limit.
headless true Run Chrome headless.
chrome_args DEFAULT_CHROME_ARGS Chrome launch arguments.
remote_browser_url nil Connect each slot to a remote chromedriver instead of starting a local one.
orphan_age :auto Remote only: on start, close sessions other warmers left behind older than this (:auto = 2 Γ— max_idle_age, nil = off).
registry_dir Dir.tmpdir Where the session registry file lives - every process that should clean up after the others must share it.
sweeper nil Remote only: settings for a ChromeSweeper on the same chromedriver, e.g. { scope: :all, max_sessions: :auto, pids_limit: 1024, interval: 60 }. The warmer's own sessions are never touched; Bidi2pdf::SessionWarmer.sweep! sweeps on demand.
retry_refused_sessions true With a sweeper: when chromedriver refuses a new session, sweep under pressure and try once more. false leaves it to the caller.
shutdown_timeout 10 Seconds shutdown waits, all in all, for its background threads and for closing the warm sessions. nil waits for ever.

Leftover sessions on a shared chromedriver

A remote chromedriver keeps a session - a whole Chrome - until someone deletes it. A warmer closes its own sessions when they idle past max_idle_age and when the process shuts down cleanly, but a process that is killed or crashes leaves its sessions open, and enough of them stop the container from starting any new Chrome. So in remote mode each warmer records the sessions it opens in a small registry file (<registry_dir>/bidi2pdf-sessions-<hash of the URL>.json, mode 0600), and on start closes recorded sessions older than orphan_age. The default, twice max_idle_age, is beyond the point where any live warmer would already have recycled its own spare, so a running process never loses one. Sessions nobody recorded (other tools on the same chromedriver) are never touched. chromedriver drops custom capabilities, so a session cannot carry a tag of its own - hence the file.

Everything here is fail-open: if the registry directory is not writable, the warmer logs one warning, instruments session_warmer.registry_unavailable.bidi2pdf, and keeps rendering - only the cleanup is off. Closed leftovers are reported as session_warmer.orphans_closed.bidi2pdf.

With a sweeper configured, the warmer also sweeps in the background every interval seconds, and when chromedriver refuses a new session ("session not created" - typically a container out of room for another Chrome) it sweeps once and tries once more.

shutdown is bounded by shutdown_timeout. A Chrome that hangs or ran out of memory answers no command, and closing each of its sessions would wait out every command timeout in turn - long enough that a stopping server looks hung. At the deadline the warmer stops waiting, logs what is still running and instruments session_warmer.shutdown_timeout.bidi2pdf (timeout, pending); shutdown returns the same tally, e.g. { "spare" => 1 }, and {} when everything finished. Sessions it could not close stay in the registry, so a later sweep closes them.

Security note: an idle warm session is an open, unauthenticated automation endpoint (chromedriver's port, Chrome's debugging port - loopback only for a local chromedriver) for as long as it waits. max_idle_age bounds that window; keep it set unless the process runs somewhere nothing else can reach those ports. With remote_browser_url, who can reach that endpoint on the network is what matters, exactly as it does without the warmer.

Leaked Chrome sessions (Bidi2pdf::ChromeSweeper)

The registry above only catches what a warmer recorded and only when the next warmer starts. A ChromeSweeper covers the rest: sessions a crashed process never recorded, a Chrome stuck in a tab that never returns, and too many sessions for the container. Run it once, when the application suspects a leak, or periodically:

sweeper = Bidi2pdf::ChromeSweeper.new("http://remote-chrome:3000/session",
                                      scope: :all, max_sessions: :auto, pids_limit: 1024, interval: 60)
sweeper.start                        # background sweeps until sweeper.stop
result = sweeper.sweep!              # or one sweep right now
result.closed                        # => [#<data Closed id="…", age=734, why=:orphan>]

# one-shot: checks twice, 10 s apart, so the unresponsive rule applies too
Bidi2pdf::ChromeSweeper.sweep!("http://remote-chrome:3000/session", check_interval: 10, dry_run: true)

# last resort: a render failed for lack of resources - sweep under pressure, try once more
Bidi2pdf::ChromeSweeper.with_retry("http://remote-chrome:3000/session", scope: :all) do
  render_the_pdf # must be safe to run twice
end

It closes, in this order: sessions older than orphan_age; sessions that failed unresponsive_checks checks in a row (no answer, or a renderer burning a whole CPU between two sweeps - an endless loop in a page); and while more than max_sessions exist, the oldest ones. It never closes a session a live bidi2pdf process holds (below), a session in own_sessions, or one younger than min_age - not even over the limit. When the limit can only be kept by closing those, it closes nothing more and reports limit_exceeded. A session is closed with chromedriver's DELETE /session/{id}, which also ends a Chrome that no longer answers BiDi.

Live sessions of other processes (leases). Several processes often share one chromedriver - Puma workers, a job worker - and each has renders in flight and warm spares the others know nothing about. So every session bidi2pdf opens is recorded in the registry with a lease, and a heartbeat thread in the owning process renews it every 20 s (SessionRegistry::Heartbeat.interval) while the session is open. Every lease carries its own TTL - three heartbeats of the process that wrote it - so a sweeper never has to guess how often another process renews. A session whose lease is still fresh belongs to a live process: no sweeper in any process closes it, and it is not even inspected. The warmer leases its sessions in remote mode too, with or without a sweeper. When the process dies - killed, crashed, OOM - the lease runs out and the session becomes a leftover like any other. Only processes that share the registry directory see each other's leases: give job workers in another container the same registry_dir on a shared volume. Sessions other tools opened have no lease; under scope: :all only min_age protects them.

Forked processes (Puma workers). The warmer belongs to the process that configured it. A child forked after that - Rails boots the app, and with it the warmer, in Puma's master before it forks its workers - does not use the copy it inherits: its threads did not survive the fork, and its slots, WebSocket connections included, are the parent's (a render on one used to time out while the parent took the answers). The child drops that copy - it closes only its own copies of the spare slots' sockets, locally, with no WebSocket close frame and no session end, so nothing of its parent's is touched - and builds its own warmer. The parent keeps its warmer.

Without further setup the child builds it on its first render, and that render then waits for the whole warm-up: size new Chrome sessions, one after the other. To warm each worker before it takes requests:

# config/puma.rb
on_worker_boot do
  Bidi2pdf::SessionWarmer.after_fork
end

Every worker then holds size spares of its own, besides the master's: with W workers, size * (W + 1) idle sessions after boot - 2 workers at size = 1 keep 3 Chromes waiting - and during renders one more per render in flight plus the replacements warming. Count them in when sizing the chromedriver (max_sessions, its memory and its process limit).

Puma's master renders nothing. To keep it from holding warm sessions nobody uses, end its warmer before it forks - at that point no worker exists yet, so no child holds a copy of those sessions:

# config/puma.rb
before_fork do
  Bidi2pdf::SessionWarmer.shutdown
end

With fork_worker, worker 0 serves requests and forks the other workers itself; before_fork runs only in the master and does not cover those forks. Puma runs before_refork in worker 0 before it forks and after_refork once the cycle is done, so end worker 0's warmer before and give it a new one after:

# config/puma.rb, with fork_worker
before_refork do
  Bidi2pdf::SessionWarmer.shutdown
end

after_refork do
  Bidi2pdf::SessionWarmer.after_fork # builds worker 0 a warmer of its own again
end

The workers worker 0 forks run on_worker_boot like any other. A render still in flight in worker 0 when it forks is fine: a child releases every slot it inherited - warm, out for a render, or being checked out or retired - and a release that fails is logged while the others go on. Not covered is a session still being built in the forking process at that instant: its socket copy in the child is left to garbage collection (a file descriptor, nothing the child or the parent uses).

Last resort: pressure. sweep!(pressure: true) closes every session nobody holds that is past min_age, without waiting for orphan_age, the unresponsive checks or the limit. with_retry does that when its block fails with a resource error - chromedriver refusing a session (SessionNotStartedError), or a connection or command dying or timing out (WebsocketError, but not CmdError, which is the page's problem) - and runs the block once more; retry_on: takes other error classes. It frees exactly what belongs to no live process: if the chromedriver is full of live renders, the second attempt fails too, and that error is raised. The warmer does the same when a new session is refused.

Setting Default Description
scope :recorded :recorded: only sessions a bidi2pdf process recorded in the registry. :all: every session on that chromedriver - only for a chromedriver your application owns, since on a shared one it closes other clients' old sessions.
orphan_age 600 Close sessions older than this many seconds. nil = off.
min_age 60 Never close a session younger than this.
unresponsive_checks 2 Close a session after this many failed checks in a row. nil = off.
max_sessions nil Session limit. :auto = floor(pids_limit Γ— pids_budget / threads_per_session), e.g. 1024 β†’ 7, 512 β†’ 3.
pids_limit nil The chromedriver container's pids limit (Docker counts threads). :auto without it means no limit.
pids_budget 0.8 Share of pids_limit the Chrome sessions may use.
threads_per_session 110 Threads one Chrome session uses.
interval nil Seconds between background sweeps (start/stop). stop(timeout: 10) waits that long for a sweep in progress, then stops waiting and returns false (nil waits for ever).
dry_run false Report what would be closed, close nothing.
registry_dir Dir.tmpdir The registry to read and update - same meaning as the warmer's setting.
own_sessions -> { [] } A callable returning the caller's live session ids; they are never touched.
lease_ttl 60 Only for registry entries written by bidi2pdf 0.1.18, which carry no TTL of their own: renewed within this many seconds counts as live.

How it tells a session's age: chromedriver's GET /sessions lists every session but no start time, and Chrome keeps none either. So the sweeper uses the registry time when there is one and otherwise attaches a second BiDi connection to the session and reads its first tab's performance.timeOrigin - that tab is created with the session. It only asks for the tab tree, that one value and the renderer CPU times; it never reads page content or logs a tab's URL.

A session counts as unresponsive only after unresponsive_checks failed checks, and every sweep is one check. A periodic sweeper gets there by itself; a single sweep! does only with check_interval: - it then checks unresponsive_checks - 1 times first (observe, which closes nothing), that many seconds apart. Without it a one-shot sweep closes only old sessions and those over the limit.

Only one sweep runs at a time, across processes too (a lock file next to the registry). A sweep never raises: failures are logged and returned in Result#errors. Every remote Bidi2pdf::Bidi::Session is now recorded and leased in the registry, not only warmer slots, and its close falls back to the HTTP DELETE when Chrome does not answer; a session it still could not close keeps its entry but loses its lease, so a sweeper takes it.

Notifications: chrome_sweeper.sweep.bidi2pdf (every sweep: counts, reason, duration), chrome_sweeper.closed.bidi2pdf (id, age, why), chrome_sweeper.unresponsive.bidi2pdf, chrome_sweeper.limit_exceeded.bidi2pdf, chrome_sweeper.failed.bidi2pdf, chrome_sweeper.retry.bidi2pdf (with_retry sweeps and tries again), and session_close_fallback.bidi2pdf when a close needed the HTTP DELETE.

From the command line:

# id, age, where the age comes from, tabs, responsive or live - no URLs
bidi2pdf sessions --remote-browser-url http://remote-chrome:3000/session [--scope recorded] [--json]

# exits 1 when a close failed or the limit is still exceeded; checks each session
# --unresponsive-checks times, --check-interval seconds apart (default 10; 0 = sweep at once)
bidi2pdf sweep --remote-browser-url http://remote-chrome:3000/session \
  [--scope all] [--older-than 600] [--max-sessions 3] [--min-age 60] \
  [--unresponsive-checks 2] [--check-interval 10] [--pressure] [--dry-run] [--json]

Customizing Chrome arguments (and blank PDFs from inline HTML)

Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS already contains one --disable-features=... entry, and Chrome only honors the last occurrence of that switch. To turn another feature off, extend that entry instead of appending a second switch, which would silently drop the defaults:

chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.map do |arg|
  arg.start_with?("--disable-features=") ? "#{arg},LocalNetworkAccessChecks" : arg
end

LocalNetworkAccessChecks is the one you are most likely to need. HTML rendered through BrowserTab#render_html_content is loaded as a data: URL, which Chrome treats as a public origin; if that HTML references assets on a private address (localhost, a Docker hostname, ...), recent Chrome versions (confirmed with Chrome 153) block those requests before they are sent. Nothing raises - the assets show up as failed network events, the server never sees a request, and the PDF comes out unstyled or blank. Disable the check only where the asset host really is private, and keep it on when rendering pages you do not control. The bidi2pdf-rails README has the full walkthrough under "Blank PDFs: Chrome's Local Network Access Check".


πŸš‚ Rails Integration

Rails integration is available as an additional gem:

# In your Gemfile
gem 'bidi2pdf-rails'

For full documentation and usage examples, visit: https://github.com/dieter-medium/bidi2pdf-rails


πŸ§ͺ Test Helpers

Bidi2pdf provides a suite of RSpec helpers (activated with pdf: true) to simplify PDF-related testing:

SpecPathsHelper

– spec_dir β†’ returns your spec directory
– tmp_dir β†’ returns your tmp directory
– tmp_file(*parts) β†’ builds a tmp file path
– random_tmp_dir(*dirs, prefix:) β†’ builds a random tmp directory

  • fixture_file(*parts) β†’ returns the path to a fixture file

PdfFileHelper

– with_pdf_debug(pdf_data) { |data| … } β†’ on failure, writes PDF to disk
– store_pdf_file(pdf_data, filename_prefix = "test") β†’ saves PDF and returns path

Rspec Matchers

  • have_pdf_page_count β†’ checks if the PDF has a specific number of pages
  • match_pdf_text β†’ checks if the PDF equals a specific text, after stripping whitespace and normalizing characters
  • contains_pdf_text β†’ checks if the PDF contains a specific text, after stripping whitespace and normalizing characters, supporting regex
  • contains_pdf_image β†’ checks if the PDF contains a specific image

ChromedriverContainer

require "bidi2pdf/test_helpers/testcontainers" you can use the chromedriver_container helper to start a ChromeDriver container for your tests. This is useful if you don't want to run ChromeDriver locally or if you want to ensure a clean environment for your tests.

This also provides the helper methods:

  • session_url β†’ returns the session URL for the ChromeDriver container
  • chromedriver_container β†’ returns the Testcontainers container object
  • create_session -> creates a Bidi2pdf::Bidi::Session object for the ChromeDriver container

With the environment variable DISABLE_CHROME_SANDBOX set to true, the container will run Chrome without the sandbox. This is useful for CI environments where the sandbox may cause issues.

Container URLs (session_url and similar) are built from the Docker host's address plus the container's mapped port. That address is resolved by the testcontainers gem β€” which already handles a tcp:/ssh: DOCKER_HOST, a native local daemon, and a sibling container reaching the daemon over the bridge gateway β€” falling back to "localhost" only if the gem can't resolve a host at all (a known gap in testcontainers-core 0.2.0 when the test process itself runs in a container on a custom network).

To override the Docker host address explicitly, set TC_HOST (e.g. TC_HOST=localhost for a test process running in a container that shares the Docker daemon's network namespace β€” true docker-in-docker). TC_HOST is a single global setting for where the daemon's published ports are reachable; it is not the name, URL, or IP of an individual container. To reach one container from another container, join the shared network and address it by alias instead (see nginx_url(use_alias: true) in the spec helpers).

Example

require "bidi2pdf/test_helpers"
require "bidi2pdf/test_helpers/images" # <= for image matching, requires lib-vips
require "bidi2pdf/test_helpers/testcontainers" # <= requires testcontainers gem

RSpec.describe "PDF generation", :pdf, :chromedriver do
  it "generates a PDF with the correct content" do
    pdf_data = generate_pdf("https://example.com/invoice/14432423")
    expect(pdf_data).to have_pdf_page_count(1)
    expect(pdf_data).to match_pdf_text("Hello, world!")
    expect(pdf_data).to contain_pdf_image(fixture_file("logo.png"))
  end
end

πŸ›  Development

# Setup
bin/setup

# Run tests
rake spec

# Open interactive console
bin/console

πŸ“œ License

This project is licensed under the MIT License.