Getting Started with macrox

macrox records every step of a PDF table extraction workflow as a YAML macro and replays it automatically on new files. Think of it as Power Query for PDFs — interactive exploration that compiles into a reproducible pipeline.


Installation

remotes::install_github("cathalbyrnegit/macrox")

# Optional but recommended
install.packages(c(
  "magick",      # page annotation and image cropping
  "ellmer",      # LLM extraction
  "validate",    # data quality rules
  "shinyAce",    # YAML editor in app
  "shinyFiles",  # filesystem browser in app
  "shinyjs",     # JS helpers in app
  "writexl",     # Excel export
  "jsonlite"     # JSON export
))

Quick start

library(macrox)

# Open a session
sess <- mx_session("report_2024.pdf")

# Scan for tables
sess |> detect_tables(pages = 1:20)

# Draw an area, extract, clean
area <- sess |> locate_area(page = 8)
sess |> select_table("monthly", page = 8, area = area, method = "bbox")
sess |> rename_columns("monthly", c(Month = "month", Total = "total"))
sess |> cast_types("monthly", c(total = "integer"))
sess |> filter_rows("monthly", exclude_where = "month == 'Total'")

# Save the macro
sess |> save_macro("my_report")

# Replay next year
mx_replay("report_2025.pdf", macro = "my_report")

Standalone app

mx_app()           # system browser
mx_app("dialog")   # RStudio dialog
mx_app("pane")     # Viewer pane

The app provides a full extraction workflow without writing any R code. Select a PDF with the filesystem browser, draw a bounding box on the page image, configure the extraction method, and hit Extract. All steps are recorded and can be exported as a YAML macro or downloaded as CSV / Excel. For a walkthrough with screenshots, see vignette("shiny-app").

Tabs:

Tab Purpose
Extract Full PDF viewer, draw-to-select area, method picker (bbox / lattice / stream / docling / llm), live preview. ← / → navigate pages. Its Struct sub-tab extracts structured records (several fields, many rows) via GLiNER2.
Tables One tab per extracted table. Rename, cast types, filter rows, split columns, undo, validate rules, column stats.
Items Extract single metadata fields (invoice numbers, dates, totals) via LLM or GLiNER2.
Steps Recorded step badges. Click one to inspect its parameters, or remove it.
Replay Run a saved macro against a new PDF. Results load into Tables.
Batch Run one macro across multiple PDFs. Download as Excel or zip of CSVs.
Export Download CSV or Excel, or edit and download the YAML macro.

Extraction methods

macrox ships five extraction engines. They can be mixed freely within the same session and macro.

lattice / stream — tabulapdf engines

For clean PDFs with visible grid lines (lattice) or whitespace-aligned columns (stream). Requires Java and the tabulapdf package (listed in Suggests — not required for other methods).

sess |> select_table("calf_monthly", page = 8, method = "lattice")
sess |> select_table("calf_stream",  page = 9, method = "stream")

docling — ML layout engine (IBM Research)

Uses Docling’s deep learning layout models to detect and extract tables. Converts scanned PDFs and handles complex multi-column layouts where positional methods fail. Runs fully offline after a one-time model download (~2-3 GB). No API key required.

# One-time setup — installs docling into a Python virtualenv
setup_docling()
# Restart R before first use
# Scan for tables using Docling's ML detection (single-pass, cached)
sess |> detect_tables(pages = 1:20, method = "docling")

# Extract a specific table
sess |> select_table_docling("financials", page = 10, table_index = 1)

# Release the document cache when done
close_docling()

Docling caches converted pages in memory, so repeated calls for different tables on the same page are fast. Use close_docling() to release the cache.

llm — LLM engine via ellmer

Sends a rendered page image to a large language model. Best for multi-level spanning headers, scanned PDFs, and any layout where positional extraction fails. Requires the ellmer package and a provider API key in .Renviron.

# Pass any ellmer chat object directly
chat <- ellmer::chat_anthropic()
sess |> select_table_llm("stillborn", page = 40, area = area, chat = chat)

# Or specify provider/model inline (chat object created automatically)
sess |> select_table_llm("stillborn", page = 40, area = area,
                          provider = "anthropic")

# Predefined schema — more accurate and consistent across replays
sess |> select_table_llm("breed_sire", page = 16, area = area,
  chat     = chat,
  schema   = c(Breed = "character", Male = "integer",
               Female = "integer", Total = "integer"),
  prompt   = "Flatten the two-row header with _ separators.")

# Refine schema without re-drawing the area
sess |> update_llm_schema("breed_sire",
  schema = c(Breed = "character", Male = "integer", Total = "integer"))

Using chat = — pass any ellmer chat object for full control over model parameters, system prompts, and provider configuration:

chat <- ellmer::chat_openai(model = "gpt-4o")
chat <- ellmer::chat_google_gemini(model = "gemini-2.0-flash")
chat <- ellmer::chat_ollama(model = "llava")

# Same chat object works for tables and items
sess |> select_table_llm("tbl", page = 1, chat = chat)
sess |> select_item("ref", prompt = "Reference number", chat = chat)

Using provider = — shorthand when defaults are fine. provider can be the name of any ellmer chat constructor without the chat_ prefix — e.g. "anthropic", "openai", "google_gemini", "openrouter", "groq", "mistral", "deepseek", "ollama", or "openai_compatible".

sess |> select_table_llm("tbl", page = 5,
  provider = "openai_compatible",
  model    = "llama3.2-vision",
  base_url = "http://localhost:1234/v1")

sess |> select_table_llm("tbl", page = 5,
  provider = "openrouter",
  model    = "anthropic/claude-opus-4.5")

API keys — set in .Renviron, using whatever environment variable the chosen ellmer::chat_<provider>() expects:

ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=AI...
OPENROUTER_API_KEY=sk-or-...

Multi-page tables

When the same table spans several consecutive PDF pages, stack_pages() extracts from each page and row-binds the results. Repeated headers are consumed automatically.

sess |> stack_pages("breed_all", pages = 10:14,
  area   = c(top = 80, left = 30, bottom = 700, right = 550),
  method = "bbox")

# When pages 2+ don't repeat the header row:
sess |> stack_pages("breed_all", pages = 10:14,
  method       = "bbox",
  header_match = FALSE)

Extracting metadata items

select_item() extracts a single field from the document — invoice numbers, dates, totals, report titles — anything that isn’t a table row. Results sit alongside tables in sess$items and are included in export_json().

# Text-based (default): sends all page text to the LLM — fast and cheap
sess |> select_item("invoice_number",
  prompt = "Extract the invoice ID or reference number.")

sess |> select_item("invoice_date",
  prompt = "The billing issue date.",
  cast   = "date:%d/%m/%Y")

sess |> select_item("total_amount",
  prompt = "The grand total payable.",
  cast   = "numeric")

# Image-based: render a specific page (better for scanned or visual PDFs)
sess |> select_item("stamp_ref", page = 1L,
  prompt = "The approval stamp reference in the top-right corner.",
  area   = c(top = 20, left = 500, bottom = 100, right = 780))

# Review all items
sess |> show_items()

GLiNER2 backend — local, no API key

The GLiNER2 backend extracts fields using a local NLP model. Runs on CPU, no API key needed.

# One-time setup
setup_gliner()

# Single field
sess |> select_item("invoice_number",
  prompt  = "Invoice ID or reference number",
  backend = "gliner")

# Batch extraction — multiple fields in one model pass (much faster)
sess |> select_items_batch(c(
  invoice_no = "Invoice ID or reference number",
  total      = "Grand total amount payable",
  due_date   = "Payment due date"
), cast = c(total = "numeric", due_date = "date:%d/%m/%Y"))

Structured record extraction

select_struct() extracts multiple structured records — like a table, but from unstructured text using GLiNER2.

sess |> select_struct("line_items",
  entity = "LineItem",
  fields = c(
    description = "Product or service description",
    quantity    = "Number of units",
    unit_price  = "Price per unit"
  ),
  list_fields = character(0),
  page = 1L)

# Convert to a data frame (with optional confidence filtering)
sess |> struct_to_df("line_items", min_confidence = 0.5)

# The result is now in sess$tables$line_items — clean, rename, export as usual
sess |> preview("line_items")

Cleaning tables

sess |> rename_columns("monthly", c(
  Month = "month", Male = "male_count", Female = "female_count", Total = "total"
))

sess |> cast_types("monthly", c(
  male_count   = "integer",
  female_count = "integer",
  total        = "integer"
  # also: "numeric", "character", "date:%d/%m/%Y", "date:%Y-%m-%d"
))

sess |> filter_rows("monthly", exclude_where = "month == 'Total'")

Forward-fill and number cleaning

Government PDFs often have merged header cells that leave blanks in rows below. fill_down() propagates the last non-blank value downward.

# Forward-fill blank cells in specific columns
sess |> fill_down("monthly", cols = "region")

# Forward-fill all columns
sess |> fill_down("monthly")

# Strip currency symbols, thousands separators, parenthetical negatives
sess |> clean_numbers("monthly", cols = c("male_count", "female_count"))

# clean_numbers() with cols = NULL targets all non-numeric columns
sess |> clean_numbers("monthly")

Suggesting column types

suggest_schema() inspects values and proposes a cast_types() call:

sess |> suggest_schema("monthly")
#> Suggested schema for 'monthly'
#>   month         ->  character
#>   male_count    ->  integer
#>   female_count  ->  integer
#>   total         ->  integer

Deriving and combining tables

These steps are all recorded in the macro and replay automatically.

# Add a derived column (expression evaluated against the data frame)
sess |> add_column("monthly", "year",  expr = "2024L")
sess |> add_column("monthly", "ratio", expr = "male_count / total")

# Row-bind tables with the same schema
sess |> stack_tables("all_years",
  tables = c("calves_2022", "calves_2023", "calves_2024"))

# Optional: pad missing columns with NA when schemas differ slightly
sess |> stack_tables("all_years",
  tables = c("calves_2022", "calves_2023"),
  .fill  = TRUE)

# Join two tables
sess |> merge_tables("combined",
  left  = "monthly",
  right = "county_totals",
  by    = "month")

Data validation

validate_table() uses the validate package DSL. Rules are stored in the macro and re-run automatically on every replay — catching data drift silently.

sess |> validate_table("monthly", rules = c(
  twelve_rows    = "nrow(.) == 12",
  no_na_month    = "!any(is.na(month))",
  positive_males = "all(male_count > 0, na.rm = TRUE)",
  totals_sum     = "all(abs(male_count + female_count - total) < 2, na.rm = TRUE)"
))
# ✔ All 4 validation rules passed for 'monthly'

# Review all validation results
sess |> show_validations()

# Strict mode — abort replay on failure (good for production pipelines)
sess |> validate_table("monthly", rules = c(...), strict = TRUE)

Reviewing and editing steps

sess |> show_steps()
# * [1]  select_table   → monthly       | page 8 [bbox]
# * [2]  rename_columns → monthly       | 4 renames
# * [3]  cast_types     → monthly       | 4 casts
# * [4]  filter_rows    → monthly       | exclude: month == 'Total'
# * [5]  add_column     → monthly       | year = 2024L
# * [6]  validate_table → monthly       | 4 rules
# * [7]  select_item    → invoice_date  | cast: date

sess |> remove_step(3)           # remove by index; clears table if extraction step
sess |> preview_all()            # inspect all tables before saving

Saving and replaying

# Save macro to disk
sess |> save_macro("my_report", path = "inst/macros/")

# Replay against a new file — returns named list of data frames
tables <- mx_replay("report_2025.pdf", macro = "my_report")

# Batch replay across multiple files
files   <- list.files("reports/", pattern = "\\.pdf$", full.names = TRUE)
results <- mx_replay_batch(files, macro = "my_report")

Comparing runs

diff <- diff_replay(
  "report_2024.pdf",
  "report_2025.pdf",
  macro = "my_report"
)
print(diff)
# macrox diff
#   Reference: report_2024.pdf
#   New:       report_2025.pdf
#
# ✔ monthly        [unchanged]
# ~ breed_by_sire  [changed] (rows: 17→18, +cols: HE, 2 cell changes)
# ✔ calf_monthly   [unchanged]

Exporting data

# CSV files — one per table
sess |> export_csv(dir = "output/")

# Single Excel workbook — one sheet per table
sess |> export_excel("output/report_tables.xlsx")

# JSON — items (metadata) + tables (row arrays) in one payload
json <- sess |> export_json()                       # returns string
sess |> export_json(path = "output/report.json")    # writes file
sess |> export_json(include_steps = TRUE)           # include macro steps

The JSON output structure:

{
  "source": "report_2024.pdf",
  "extracted": "2026-05-01T14:32:00",
  "items": {
    "invoice_number": "INV-9821",
    "total_amount": 1234.56
  },
  "tables": {
    "monthly": [
      { "month": "January", "total": 4700 },
      { "month": "February", "total": 11270 }
    ]
  }
}

Agent integration

test_extraction() is the R-Core Judge for agentic workflows. It runs one extraction pass and returns a structured evaluation report — enabling an MCP agent to self-correct its parameters in a feedback loop.

# Single test pass — no session needed
report <- test_extraction(
  file        = "invoice.pdf",
  page        = 1L,
  area        = c(top = 200, left = 30, bottom = 600, right = 780),
  method      = "bbox",
  header_rows = 1L
)

report$status
#> [1] "needs_alignment"

report$metrics
#> $rows_extracted      [1] 12
#> $unnamed_headers     [1] "V1" "V2"
#> $header_spill        [1] TRUE
#> $empty_columns       list()

report$guidance
#> [1] "Header text detected in data rows — increase header_rows by 1. |
#>       Unnamed columns (V1, V2) — adjust area coords or increase header_rows."

The return value serialises directly to JSON via jsonlite::toJSON(), making it straightforward to wire into an MCP test_parameters tool. The agent loop runs test_extraction() repeatedly — adjusting area, header_rows, and method based on $guidance — until status == "success", then calls save_macro() to lock the verified parameters.

Pre-flight validation before replay:

# Validate a macro file without running it
result <- validate_macro("report_2025.pdf", macro = "my_report")
print(result)
# ✔ Macro valid
#   ✔ [1] select_table / monthly
#   ✔ [2] rename_columns / monthly

The YAML macro

All step types serialise cleanly. The full extraction parameters are stored so replay is exact.

macro:
  name: my_report
  created: '2026-05-01 14:32'
  source: report_2024.pdf
  n_steps: 10

steps:
  - step: select_table
    label: monthly
    page: 8
    method: bbox
    area: [80, 30, 500, 750]
    header_rows: 1

  - step: select_table_docling
    label: financials
    page: 10
    table_index: 1

  - step: select_table_llm
    label: breed_sire
    page: 16
    area: [60, 25, 620, 790]
    provider: anthropic
    model: claude-opus-4-8
    schema:
      Breed: character
      Male: integer
      Total: integer
    prompt: "Flatten the two-row header."

  - step: select_item
    label: report_date
    prompt: "The publication date of this report."
    cast: "date:%B %Y"
    backend: llm
    provider: anthropic

  - step: select_struct
    label: line_items
    entity: LineItem
    fields:
      description: "Product or service description"
      quantity: "Number of units"
    page: 1

  - step: rename_columns
    table: monthly
    mapping:
      Month: month
      Total: total

  - step: cast_types
    table: monthly
    types:
      total: integer

  - step: fill_down
    table: monthly
    cols: [region]

  - step: clean_numbers
    table: monthly
    cols: [total]

  - step: validate_table
    table: monthly
    strict: false
    rules:
      twelve_rows: "nrow(.) == 12"
      no_na:       "!any(is.na(month))"

Shiny module

Embed macrox in an existing Shiny app:

# UI
bslib::accordion_panel(
  "PDF Import",
  icon = shiny::icon("file-pdf"),
  macrox::macrox_ui(ns("pdf_import"))
)

# Server
pdf_result <- macrox::macrox_server("pdf_import")

observe({
  tables <- pdf_result$tables()
  req(length(tables) > 0)
  for (nm in names(tables)) your_upload_fn(tables[[nm]], target = nm)
})

Choosing an extraction method

Situation Recommended
Clean PDF with visible grid lines lattice
Whitespace-aligned columns, no grid stream
Charts and tables on the same page bbox + area =
Numbers running together bbox + col_gap =
Scanned PDF, no selectable text docling or llm
Complex layout, offline docling
Multi-level / spanning headers llm with schema =
Complex irregular layout llm
No API key / offline bbox, tabulapdf, or docling
Single metadata fields select_item() with llm or gliner
Structured records from text select_struct() with GLiNER2

Dependencies

Package Role Required
pdftools Word positions (bbox), page text, rendering Yes
stringdist Fuzzy caption matching Yes
yaml Macro read/write Yes
cli Console output Yes
rlang %\|\|% operator Yes
tabulapdf lattice / stream (needs Java) Suggests
reticulate Python bridge for Docling and GLiNER2 Suggests
magick Page annotation; image cropping for LLM Suggests
ellmer LLM extraction Suggests
validate Data quality rules Suggests
jsonlite JSON export Suggests
shiny + bslib + DT Standalone app and module Suggests
shinyFiles Filesystem browser in app Suggests
shinyAce YAML editor with syntax highlighting Suggests
shinyjs JS helpers in app Suggests
writexl Excel export Suggests
zip Zip-of-CSVs export Suggests