{"id":50561806,"url":"https://github.com/retsef/rpdfium","last_synced_at":"2026-06-04T12:01:08.036Z","repository":{"id":356679155,"uuid":"1233616635","full_name":"retsef/rpdfium","owner":"retsef","description":"Ruby implementation of Pdfium","archived":false,"fork":false,"pushed_at":"2026-05-18T06:02:59.000Z","size":473,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-18T08:14:16.356Z","etag":null,"topics":["pdf","pdfium","ruby"],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/retsef.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-09T06:39:29.000Z","updated_at":"2026-05-18T06:03:09.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/retsef/rpdfium","commit_stats":null,"previous_names":["retsef/rpdfium"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/retsef/rpdfium","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retsef%2Frpdfium","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retsef%2Frpdfium/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retsef%2Frpdfium/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retsef%2Frpdfium/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/retsef","download_url":"https://codeload.github.com/retsef/rpdfium/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/retsef%2Frpdfium/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33903134,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-04T02:00:06.755Z","response_time":64,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["pdf","pdfium","ruby"],"created_at":"2026-06-04T12:01:06.900Z","updated_at":"2026-06-04T12:01:08.026Z","avatar_url":"https://github.com/retsef.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# rpdfium\n\nRuby bindings for [PDFium](https://pdfium.googlesource.com/pdfium/), the\nPDF engine that powers Chrome's viewer. Provides text extraction with\ncharacter-level metadata, vector path access, image extraction, form\nfields, page rendering, and pdfplumber-style table detection.\n\nInspired by [`pypdfium2`](https://github.com/pypdfium2-team/pypdfium2)\n(bindings layout) and [`pdfplumber`](https://github.com/jsvine/pdfplumber)\n(table heuristics).\n\n```ruby\nrequire \"rpdfium\"\n\nRpdfium.open(\"invoice.pdf\") do |doc|\n  puts doc.metadata[:title]\n\n  doc.each do |page|\n    puts page.text\n    Rpdfium::Table::Extractor.new(page).extract.each do |table|\n      table.each { |row| puts row.inspect }\n    end\n  end\nend\n```\n\n## Why\n\nThe Ruby ecosystem has `pdf-reader` (text only, slow on complex docs),\n`origami` (security-research focused), and `hexapdf` (great for\nmanipulation but text extraction is approximate). None give you\ncharacter-level bounding boxes, real vector path geometry, or table\nextraction. `rpdfium` fills that gap by binding the same battle-tested\nC++ engine that powers Chrome's PDF viewer.\n\nIn practice it matches the speed of Python's `pypdfium2` on text\nextraction and is **15-56× faster than `pdfplumber`** while using\n**5-7× less memory** on large documents. See [Performance](#performance)\nfor details.\n\n## Installing PDFium\n\n`rpdfium` itself ships only Ruby code. The native library is loaded\nfrom one of, in order:\n\n- `ENV[\"PDFIUM_LIBRARY_PATH\"]` (highest priority — point to a\n  `libpdfium.{so,dylib,dll}` of your choice)\n- the [`rpdfium-binary`](https://github.com/retsef/rpdfium-binary)\n  companion gem (recommended), which ships precompiled PDFium binaries\n  for major platforms via [bblanchon/pdfium-binaries](https://github.com/bblanchon/pdfium-binaries)\n- the system `libpdfium` (if installed via your package manager)\n\n### Recommended: use `rpdfium-binary`\n\n```bash\ngem install rpdfium-binary\n```\n\nRubyGems picks the right platform-specific gem automatically. Supported\nplatforms include `x86_64-linux`, `aarch64-linux`, `x86_64-linux-musl`,\n`aarch64-linux-musl`, `arm64-darwin`, `x86_64-darwin`, `x64-mingw-ucrt`,\n`x86-mingw32`, `aarch64-mingw-ucrt`. For unsupported platforms the\ngeneric Ruby-platform gem is installed and the binary is downloaded on\nfirst use into the user data directory.\n\nAdd to your `Gemfile`:\n\n```ruby\ngem \"rpdfium\"\ngem \"rpdfium-binary\"\n```\n\n### Alternative: manual `PDFIUM_LIBRARY_PATH`\n\nUseful in containers, CI, or when you need a specific PDFium build:\n\n```bash\n# macOS arm64\ncurl -L https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-mac-arm64.tgz | tar xz\nexport PDFIUM_LIBRARY_PATH=$PWD/lib/libpdfium.dylib\n```\n\n## Architecture\n\nThree layers, mirroring `pypdfium2`:\n\n1. **`Rpdfium::Raw`** — pure FFI bindings, 1:1 with the C API\n   (`FPDF_*`, `FPDFText_*`, `FPDFBitmap_*`, `FPDFPath_*`,\n   `FPDFImageObj_*`, `FPDFAnnot_*`). Use directly if you need something\n   the wrappers don't expose.\n2. **`Rpdfium::Document, ::Page, ::TextPage, ::Image::Embedded,\n   ::Annotation, ::Form::Field, ::Search, ::Outline, ::Attachment`** —\n   RAII-style wrappers with `ObjectSpace.define_finalizer` so handles\n   are released even if you forget `close`.\n3. **`Rpdfium::Table::Extractor`** — table detection on top of layer 2,\n   with `Rpdfium::Table::Debugger` for visual debugging.\n\n## What you can do\n\n### Text\n\n```ruby\npage.text                       # plain string\npage.text_in_bbox(left: 50, top: 100, right: 300, bottom: 150)\n```\n\n### Character-level metadata\n\nPer-char data essential for layout-aware processing — bounding box,\nfont, weight, origin, rotation angle, plus PDFium's \"character\nprovenance\" flags:\n\n```ruby\npage.chars.first\n# {\n#   char:     \"T\",\n#   codepoint: 84,\n#   x0: 72.0, x1: 79.2, top: 100.5, bottom: 112.3,\n#   origin_x: 72.0, origin_y: 110.8,\n#   angle:    0.0,                  # radians (rotated text)\n#   fontsize: 12.0,\n#   font:     \"Helvetica-Bold\",\n#   weight:   700,\n#   render_mode: 0,                 # 0=fill 1=stroke 2=both 3=invisible\n#   generated: false,               # true → inserted by PDFium (e.g. spaces)\n#   hyphen:    false,               # true → soft-hyphen for line break\n#   unicode_error: false            # true → couldn't map glyph to unicode\n# }\n```\n\n`generated`/`hyphen`/`unicode_error` are the **artefact recognition\nflags** — distinguishing real characters from PDFium-synthesized ones is\ncrucial when you don't want fake whitespace to widen a column.\n\nLoose char boxes (proportional to font size, more stable for layout\nalgorithms):\n\n```ruby\npage.chars(loose: true)\n```\n\nCluster chars into words automatically:\n\n```ruby\npage.words(x_tolerance: 3.0, y_tolerance: 3.0)\n# [{ text: \"Invoice\", x0: 72.0, x1: 110.5, top: 100.5, bottom: 112.3,\n#    fontsize: 12.0, font: \"Helvetica-Bold\", chars: [...] }, ...]\n```\n\n### Vector paths\n\nReal path-segment iteration (not just bounding boxes), with state\nmachine for `closepath`. Useful for table line detection, signatures,\nform layout analysis:\n\n```ruby\npage.line_segments\n# [{ x0: 72.0, y0: 100.0, x1: 540.0, y1: 100.0, stroke_width: 0.5 }, ...]\n\npage.horizontal_lines\npage.vertical_lines\n```\n\n### Images\n\n```ruby\npage.images.each do |img|\n  meta = img.metadata\n  puts \"#{meta[:width]}×#{meta[:height]} @ #{meta[:horizontal_dpi]} DPI, \" \\\n         \"#{meta[:colorspace]}\"\n  puts \"filters: #{img.filters}\"   # e.g. [\"DCTDecode\"] for JPEG\n\n  # JPEG passthrough when filters == [\"DCTDecode\"]; otherwise rendered to PNG\n  img.save(\"img_#{img.bbox[:x0].to_i}.jpg\")\n\n  # Or get raw/decoded bytes for custom processing\n  img.raw_bytes      # as stored\n  img.decoded_bytes  # post-filters (raster)\nend\n```\n\n### Annotations \u0026 links\n\n```ruby\npage.annotations.each do |a|\n  puts \"#{a.subtype}: #{a[:Contents]} at #{a.bbox.inspect}\"\nend\n\npage.links.each do |link|\n  puts link.link_uri || \"→ page #{link.link_dest_page}\"\nend\n```\n\n### Forms (read-only)\n\n```ruby\ndoc = Rpdfium.open(\"form.pdf\")\nputs doc.form_type            # :acroform / :xfa_full / :xfa_foreground / :none\n\ndoc.each do |page|\n  page.form_fields.each do |f|\n    pp f.to_h\n    # { name: \"name\", type: :textfield, value: \"Mario Rossi\",\n    #   readonly: false, required: true, bbox: {...} }\n  end\nend\n```\n\n### Outline (bookmarks) \u0026 attachments\n\n```ruby\nRpdfium::Outline.flatten(doc.outline) do |item, depth|\n  puts \"#{\"  \" * depth}- #{item.title} (page #{item.page_index})\"\nend\n\ndoc.attachments.each { |a| a.save(\"attached_#{a.name}\") }\n```\n\n### Search\n\n```ruby\npage.search(\"totale\", match_case: false).each_match do |m|\n  puts \"found '#{m[:text]}' at #{m[:rects].first.inspect}\"\nend\n```\n\n### Rendering\n\n```ruby\n# Pure-Ruby PNG writer, zero deps:\npage.render_to_png(\"page.png\", scale: 2.0, include_annotations: true,\n                   include_forms: true)\n\n# Or get raw RGBA/BGRA/Gray bytes:\nw, h, bytes, stride = page.render(scale: 2.0, output: :rgba)\n```\n\n### Tables\n\n`pdfplumber`-style settings — every parameter you'd recognize:\n\n```ruby\nextractor = Rpdfium::Table::Extractor.new(page,\n                                          vertical_strategy:        :lines,    # :lines / :lines_strict / :text / :explicit\n                                          horizontal_strategy:      :lines,\n                                          snap_tolerance:           3.0,\n                                          join_tolerance:           3.0,\n                                          edge_min_length:          3.0,\n                                          edge_min_length_prefilter: 1.0,\n                                          intersection_tolerance:   3.0,\n                                          min_words_vertical:       3,\n                                          min_words_horizontal:     1,\n                                          text_x_tolerance:         3.0,\n                                          text_y_tolerance:         3.0,\n                                          explicit_vertical_lines:  [],         # [Float] x-coords or [Hash{x:, top:, bottom:}]\n                                          explicit_horizontal_lines: [],\n                                          auto_fallback:            true        # try :text if :lines finds nothing\n)\n\nextractor.tables.each do |table|\n  table.bbox             # =\u003e [x0, top, x1, bottom]\n  table.rows             # =\u003e Array\u003cArray\u003cbbox|nil\u003e\u003e\n  table.columns          # =\u003e Array\u003cArray\u003cbbox|nil\u003e\u003e\n  table.extract          # =\u003e Array\u003cArray\u003cString\u003e\u003e\nend\n\nextractor.extract  # shortcut: =\u003e [[[String, ...], ...], ...]   (list of tables)\nextractor.edges    # post-snap/join edges\nextractor.intersections   # Hash{[x,y] =\u003e {v:[edges], h:[edges]}}\nextractor.cells           # Array\u003cbbox\u003e\n```\n\nThe pipeline mirrors `pdfplumber.TableFinder` 1:1 and uses the same\nalgorithms for words-to-edges, intersections-to-cells, cells-to-tables.\n\nVisual debugger (saves PNG with overlay: red lines, green intersections,\nblue table fills):\n\n```ruby\nRpdfium::Table::Debugger.visualize(page, \"debug.png\",\n                                   vertical_strategy: :lines)\n```\n\n### Form-aware extraction (font filtering)\n\nSome PDFs are \"filled-out forms\" — F24, tax declarations, payment\nslips, government forms — where the form template and the entered\ndata both exist as static graphics text on the page (no AcroForm\nfields, no tagged structure). On these PDFs the table pipeline picks\nup the template labels as noise alongside the data.\n\nThe robust strategy is to separate chars by **role** using their\nfont: the template typically uses proportional fonts (Futura, Times,\nHelvetica) while the data layer uses a single font (often Courier\nmonospace, or Helvetica at a specific size).\n\n```ruby\nRpdfium.open(\"f24.pdf\") do |doc|\n  page = doc.page(0)\n\n  # Discover what fonts are on the page\n  page.font_inventory.first(5).each do |g|\n    puts \"#{g[:font].ljust(20)} h=#{g[:height]} | #{g[:count]} chars | #{g[:sample][0,40]}\"\n  end\n  # Futura-Light          h=8.3  |  946 chars | \"cognome, denominazione o ragione sociale\"\n  # Courier               h=10.5 |  365 chars | \"01234567890Azienda S.R.L.P\"\n  # Futura-Bold           h=10.4 |  249 chars | \"CODICE FISCALEDATI ANAGRAFICI...\"\n  # ...\n\n  # Extract just the entered data, line by line\n  page.lines(font: \"Courier\").each { |l| puts l }\n  # =\u003e \"Soggetto:  Azienda S.R.L.  ( 01234567890 )\"\n  # =\u003e \"1001  11  2021  499,81  0,00\"\n  # =\u003e \"1712  12  2021  32,46  0,00\"\n  # =\u003e \"1701  11  2021  0,00  295,89\"\n  # =\u003e \"532,27  295,89  236,38\"\n  # =\u003e ...\nend\n```\n\nThree primitives:\n\n- `Page#font_inventory` — distribution by `(font, height, weight)`,\n  with counts and samples for ispection\n- `Page#chars_where(font:, height:, weight:, bbox:, where:)` —\n  filter chars by any combination of criteria\n- `Page#lines(font:, ...)` — high-level helper: filter + word\n  extraction + line clustering, returns `Array\u003cString\u003e`\n\nWorks on F24 payment forms, VAT periodic communications, withholding\ntax declarations, and similar government forms — anywhere the data\nsits on a printed template as text.\n\n#### Label-value pairing\n\n`Page#label_value_pairs` associates each extracted value with the\nsemantic label from the template that describes it. Useful when you\nwant machine-readable `field_name → field_value` pairs without\nhard-coding the form layout.\n\n```ruby\nRpdfium.open(\"f24.pdf\") do |doc|\n  pairs = doc.page(0).label_value_pairs(\n    data_font: \"Courier\",\n    template_font: /^Futura/,\n    data_filter: -\u003e(t) { t.match?(/^[\\d.,]+$/) }\n  )\n  pairs.each do |p|\n    col = p[:labels][:col]\n    row = p[:labels][:row]\n    puts \"#{p[:value].ljust(12)} → col: #{col}, row: #{row}\"\n  end\nend\n# 499,81    → col: \"importi a debito versati\"\n# 1.615,90  → col: \"SALDO (M-N) +/–\", row: \"EURO +\"   ← saldo finale\n```\n\nThe algorithm clusters template words into coherent labels, then for\neach value finds the `:col` label (positioned above) and the `:row`\nlabel (positioned to the left).\n\n#### Composable primitives for complex forms\n\nFor complex forms with repeating tables, boxed-layout cells, or\nmulti-word values, compose three primitives:\n\n**`Util::WordMerger`** — join adjacent words on the same line:\n\n```ruby\nmerger = Rpdfium::Util::WordMerger.new(x_gap: 20.0, y_tol: 3.0)\nmerged = merger.merge_by_proximity(words)\n# or, with labels mapping to preserve checkbox grids:\nmerged = merger.merge_by_label(words, label_per_word)\n# or, only merge orphans (no label assigned):\nmerged = merger.merge_unlabeled(words, label_per_word)\n```\n\n**`Util::ColumnInference`** — identify data columns by alignment:\n\n```ruby\ninference = Rpdfium::Util::ColumnInference.new(\n  x_tolerance: 3.0,\n  min_size: 3,\n  cv_threshold: 0.15\n)\ncolumns = inference.infer(words)\n# =\u003e [[word1, word2, ..., word12], ...]\n```\n\nAlgorithm: cluster by `x0` (left-align) AND `x1` (right-align), split\ncolumns at large vertical gaps, filter by gap-regularity (coefficient\nof variation \u003c 0.15) to exclude false positives.\n\n**`Util::LabelMatcher`** with column inference enables header\npropagation for repeating tables (e.g. 770 Quadro ST with rows\nST2..ST13 sharing column headers printed once at the top):\n\n```ruby\nmatcher = Rpdfium::Util::LabelMatcher.new(\n  column_inference: Rpdfium::Util::ColumnInference.new\n)\npairs = page.label_value_pairs(data_font: \"Courier\", matcher: matcher)\n```\n\nFor boxed-layout forms (cells separated by ~10pt with template\ngraphics for decimals), pass `inject_spaces: false, x_tolerance: 15.0`\nto `label_value_pairs` and `row_max_dx: 400.0` to the matcher.\n\nSee `examples/adapters/` for complete working adapters that compose\nthese primitives for specific Italian tax forms (Modello 770,\nComunicazione IVA).\n\n### Struct tree (Tagged PDF)\n\nFor tagged PDFs (PDF/UA, accessibility-friendly exports from\nWord/LibreOffice/InDesign), `Page#struct_tree` exposes the document's\nlogical structure (Document → P, H1, Table, TR, TH, TD, Figure, ...)\nindependently of the visual layout. This gives **zero-geometry**\nextraction with semantic typing (TH vs TD, RowSpan, ColSpan, Lang).\n\n```ruby\npage.struct_tree do |tree|\n  next if tree.nil? || tree.empty?\n\n  tree.tables.each do |table|\n    rows = table.children.select { |c| c.type == \"TR\" }\n    rows.each do |row|\n      cells = row.children.select { |c| %w[TH TD].include?(c.type) }\n      puts cells.map(\u0026:text).map(\u0026:strip).inspect\n    end\n  end\nend\n# =\u003e [\"Region\", \"Revenue\", \"Growth\"]      (TH)\n# =\u003e [\"Italy\", \"1.250.000\", \"+12%\"]       (TD)\n# =\u003e ...\n```\n\nAPI summary:\n\n```ruby\ntree = page.struct_tree     # → Tree or nil (nil if not tagged)\ntree.empty?                 # true for \"tagged but placeholder\" PDFs\ntree.roots                  # → [Element, ...]\ntree.walk { |el| ... }      # depth-first\ntree.find_all(type: \"P\")\ntree.tables                 # → [Element, ...] where type == \"Table\"\n\nelement.type                # \"P\", \"Table\", \"TR\", \"TD\", ...\nelement.children            # → [Element, ...]\nelement.parent              # → Element or nil\nelement.text                # text via MCID + ActualText override\nelement.actual_text         # /ActualText (for ligature/math resolution)\nelement.alt_text            # /Alt (Figure / Formula)\nelement.lang                # \"it-IT\", \"en-US\", ...\nelement.marked_content_ids  # → [Integer]\nelement.attributes          # → { name =\u003e value }\n```\n\nThree possible states of `page.struct_tree`:\n\n| PDF type | returns |\n| --- | --- |\n| Not tagged (most PDFs from line-of-business software, scanned PDFs) | `nil` |\n| Tagged but empty (some bank statements have placeholder StructTreeRoot) | `Tree` with `empty? == true` |\n| Properly tagged (Word/LibreOffice/InDesign export with accessibility tags) | Navigable `Tree` |\n\nLifecycle: prefer the block form for deterministic close. The implicit\nform (no block) leaves cleanup to `FPDF_CloseDocument` — no leak, just\nthe tree stays in memory until the document is closed.\n\n## Performance\n\nMeasured on 4 PDFs of increasing complexity, best-of-3 runs after a\nwarm-up, isolated in subprocesses to capture clean peak RSS. Versions\nunder test: `rpdfium 0.3.13`, `pdfplumber 0.11.9`, `pypdfium2 5.6.0`.\n\n| Test corpus | Pages | Size | What it stresses |\n| --- | ---: | ---: | --- |\n| `sample.pdf` | 1 | 18 KB | Plain text baseline |\n| `form.pdf` | 1 | 107 KB | Char-per-text-object kerning, Form XObject, tables |\n| `complex.pdf` | 85 | 60 MB | Magazine-style document, dense text + heavy graphics |\n| `report.pdf` | 226 | 322 KB | Rotated pages (90°), small fonts, ~15 tables per page |\n\n### Speed\n\n| Corpus | Task | rpdfium | pypdfium2 | pdfplumber | speedup vs pdfplumber |\n| --- | --- | ---: | ---: | ---: | ---: |\n| sample.pdf (1 pag) | text | 4 ms | 4 ms | 75 ms | **21×** |\n| sample.pdf (1 pag) | tables | 4 ms | n/a | 70 ms | **16×** |\n| form.pdf (1 pag) | text | 12 ms | 13 ms | 538 ms | **44×** |\n| form.pdf (1 pag) | tables | 25 ms | n/a | 575 ms | **23×** |\n| complex.pdf (85 pag) | text | 190 ms | 183 ms | 7.76 s | **41×** |\n| complex.pdf (85 pag) | tables | 231 ms | n/a | 7.07 s | **31×** |\n| report.pdf (226 pag) | text | 412 ms | 397 ms | 23.26 s | **56×** |\n| report.pdf (226 pag) | tables | 1.68 s | n/a | 25.25 s | **15×** |\n\n`pypdfium2` does not implement table extraction (it's a raw FFI binding\nto PDFium, not a full pipeline). It's listed as the \"pure PDFium speed\nfloor\" for text — rpdfium matches it within ±5%, showing that the Ruby\nFFI overhead is not measurable.\n\n### Memory (peak RSS)\n\n| Corpus | rpdfium | pypdfium2 | pdfplumber | pdfplumber/rpdfium |\n| --- | ---: | ---: | ---: | ---: |\n| sample.pdf | 29 MB | 20 MB | 40 MB | 1.4× |\n| form.pdf | 32 MB | 22 MB | 45 MB | 1.4× |\n| complex.pdf | 106 MB | 69 MB | 535 MB | **5.0×** |\n| report.pdf | 136 MB | 41 MB | 1003 MB | **7.4×** |\n\nThe memory gap widens with workload size. On a 226-page document\npdfplumber uses ~1 GB; rpdfium stays under 140 MB. For server-side\nbatch processing this is the difference between a 256 MB container and\na 2 GB one.\n\n### Headline numbers\n\nOn large PDFs (226 pages, dense layout):\n\n- **rpdfium completes both text + tables in ~2.1 s using 136 MB**\n- **pdfplumber needs ~48 s and 1 GB** for the same work\n\nAcross the four corpora the median speedup vs pdfplumber is **27× on\ntext**, **22× on tables**. rpdfium scales linearly with page count\n(thanks to PDFium's C++ engine); pdfplumber's pure-Python pipeline\ndegrades super-linearly on large documents.\n\n### Methodology\n\nEach measurement is the **minimum of 3 timed runs after a warm-up run**\n(to neutralize OS page cache effects on the 60 MB `complex.pdf`).\nSubprocess isolation per measurement ensures clean RSS reading via\n`resource.getrusage` / `/proc/self/status`. The benchmark harness is\na small Ruby driver that shells out to three runners (one Ruby script\nusing `rpdfium`, two Python scripts using `pdfplumber` and\n`pypdfium2`), parses the JSON each emits, and aggregates the results.\n\nOutput quality has been spot-checked: rpdfium matches pypdfium2 char\ncount within ±1 char (rounding on the trailing newline). pdfplumber\nreturns ~2% fewer chars on locale-formatted numbers due to a different\nword-tokenization for thousand-separator punctuation (e.g. `1.250.000`\nsplit on periods).\n\n## Memory safety\n\n- `FPDF_LoadMemDocument64` does **not** copy the input bytes. The\n  `Document` wrapper holds an FFI buffer reference for its lifetime so\n  the GC can't free it early.\n- Every PDFium handle (`*_Close*`) is wired to\n  `ObjectSpace.define_finalizer` so abandoned objects don't leak native\n  memory.\n- `FPDF_InitLibrary` is called once per process under `Mutex`;\n  `FPDF_DestroyLibrary` runs via `at_exit`.\n- `Document#close` releases in cascade: form-fill env → cached pages →\n  document handle.\n\n## Roadmap\n\n| Status | Feature |\n|---|---|\n| ✅ | Document open (path / IO / bytes / password) |\n| ✅ | Document metadata, permissions, file version |\n| ✅ | Page text + bbox-bounded text |\n| ✅ | Per-character bounding boxes (tight \u0026 loose) |\n| ✅ | Char metadata: font, weight, origin, angle, render mode |\n| ✅ | PDFium-generated char detection (artefact filtering) |\n| ✅ | Word clustering (layout-aware) |\n| ✅ | Vector path segments (real geometry, not bbox) |\n| ✅ | Image extraction (raw + decoded + rendered) |\n| ✅ | Annotations + link URI/dest |\n| ✅ | AcroForm field reading |\n| ✅ | Bookmarks (outline) |\n| ✅ | File attachments |\n| ✅ | Internal text search |\n| ✅ | Page rendering to RGBA/BGRA/Gray |\n| ✅ | Pure-Ruby PNG writer (zero deps) |\n| ✅ | Table extraction — `:lines` strategy |\n| ✅ | Table extraction — `:text` strategy |\n| ✅ | Table extraction — `:explicit` strategy |\n| ✅ | Visual table debugger |\n| ✅ | [`rpdfium-binary`](https://github.com/retsef/rpdfium-binary) companion gem with prebuilt PDFium |\n| ✅ | Structure tree traversal (PDF tagged → semantic tables / `Page#struct_tree`) |\n| ✅ | Form-aware extraction via font filtering (`Page#font_inventory`, `chars_where`, `lines`) |\n| ✅ | Semantic label-value pairing on filled forms (`Page#label_value_pairs`, `Util::LabelMatcher`) |\n| 🚧 | XFA form support |\n| 🔮 | OCR fallback for scanned PDFs (via tesseract bindings) |\n| 🔮 | Write APIs (we're read-only by design for now) |\n\n## Why not pure-Ruby?\n\nA correct PDF text extractor needs to interpret the content stream\n(operators, font encodings including CMap-based CIDs, ToUnicode maps,\nActualText overrides, marked content). PDFium has ~15 years of\nedge-case fixes baked in. Reimplementing it in Ruby would take years\nand still be slower. FFI is the right call.\n\n## License\n\nApache-2.0 (same as PDFium itself).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fretsef%2Frpdfium","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fretsef%2Frpdfium","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fretsef%2Frpdfium/lists"}