Migrating to 5.0 (PDF)

Datagrunt 5.0 replaces the PDF extraction stack behind PDFReader and PDFWriter. This guide maps every 4.x PDF option and output to its 5.0 form. CSV, Excel, and Parquet code needs no changes.

What changes

  • One engine. PyMuPDF, pdfplumber, and pytesseract are gone. Every PDF is read by PDFium (via pypdfium2), and scanned pages by the system tesseract program when it is installed.
  • A typed document. The output is a typed document model — headings, paragraphs, key-value pairs, tables, lists, and figures — in reading order, with section scope and hidden-text flags. The 4.x unified and native schemas are gone, so the shape of to_dicts(), the DataFrame columns, and every written file change.
  • Explicit outputs. Every write needs its path, every file is written atomically, and PDFWriter gains Parquet, CSV, and checksummed bundles.
  • Python 3.11 or newer. 5.0 drops Python 3.10 for the whole library, so check your interpreter before you upgrade.

See the PDF Parsing guide for the 5.0 API in full.

# 4.x
from datagrunt import PDFReader, PDFWriter

reader = PDFReader("report.pdf", engine="pdfium")
document = reader.to_dicts(drop_layout_tables=True)    # {"document": {"pages": [...]}}
pages = reader.total_pages

writer = PDFWriter("report.pdf")
writer.write_json()                                     # output.json
writer.write_markdown("report.md", image_output_dir="images")
writer.render_pages_as_images(dpi=300, image_format="jpg")   # page_images/report_page_01.jpg
# 5.0
from datagrunt import PDFReader, PDFWriter

reader = PDFReader("report.pdf")
rows = reader.to_dicts()                                # one dict per element
document = reader.document                              # the whole typed document
pages = reader.metadata.page_count

writer = PDFWriter("report.pdf")
writer.write_json("output.json")                        # the path is required
writer.write_bundle("report_bundle")                    # Markdown that links its figures, checked
writer.render_pages_as_images("page_images", dpi=300)   # page_images/page_0001.png

Constructor (PDFReader / PDFWriter)

4.x 5.0
PDFReader(filepath, engine="pdfium", workers=1, native=False, *, …) PDFReader(source, *, password=None, workers=1, ocr="auto", dpi=200, vision_dpi=300, max_render_pixels=16_000_000, min_image_dimension=40, strict=False). Every option after source is keyword-only.
engine= ("pdfium" / "pymupdf") Removed: there is one engine.
native= Removed, with the lean native schema.
ocr_standard_dpi, ocr_large_format_dpi, ocr_large_format_dimension Replaced by vision_dpi (default 300). Every render is bounded by max_render_pixels, so a large sheet lowers its DPI rather than exceeding the pixel bound; there is no size threshold.
render_dpi (default 300) dpi (default 200) for page images; OCR renders at vision_dpi. vision_dpi must be at least dpi, so dpi above 300 also needs a vision_dpi at least as high. To keep 4.x’s 300-DPI page images, pass dpi=300.
min_image_dimension=0 kept every image The minimum is 1: min_image_dimension=0 raises ValueError. Pass 1 to keep every image. The default is still 40.
(none) password=, ocr="auto" / "always" / "never", and strict= (raise on a bad page instead of recording it as an error page).
filepath: a PDF, a 4.x JSON file, or a 4.x result dict source: a PDF, a bundle directory or its manifest.json, or a 5.0 document dict (the JSON PDFWriter.write_json writes, parsed). 4.x JSON output and dicts do not load in 5.0: re-extract from the PDF. A .json path is not a source; pass json.loads of it. PDFWriter also takes a PDFDocument; such a writer can’t render_pages_as_images or write_bundle, and raises ValueError.
A missing path raised FileNotFoundError at construction; a file that wasn’t a PDF failed on first use (PdfiumError) Both classes check the source at construction: a missing path raises FileNotFoundError; a non-PDF (no %PDF in its first 1,025 bytes, as PDFium reads it) raises ValueError. Options are checked there too.

Reading

4.x 5.0
to_dicts(image_output_dir=None, drop_layout_tables=False) returned {"document": {"pages": [...]}} to_dicts() returns one dict per element, in document order: doc_id, element_id, page_number, reading_order, kind, scope_path, text, markdown, bbox, route, integrity_verified, hidden_reasons. For the whole structured document, use reader.document (a PDFDocument) or PDFWriter.write_json. The shape change is breaking.
to_dataframe() / to_arrow_table() (with drop_layout_tables) The same rows as to_dicts(), typed (Polars / Arrow), with no arguments.
drop_layout_tables= Removed. 5.0 emits a table only for a ruled grid of at least 2 × 2, or for aligned borderless rows that are not prose, a masthead, or key-value pairs, so layout boxes are not tables.
image_output_dir= Removed from reading. Figures load lazily (figure.read_bytes()); write them with PDFWriter.extract_images() or write_bundle().
get_sample() parsed only the first page (a preview) get_sample(n_rows=20) returns the first rows of the element frame; the whole document is extracted first (then cached).
total_pages reader.metadata.page_count (or len(reader.pages)). PDFWriter has no page count.
A zero-byte or non-PDF file returned empty objects Raises ValueError at construction (no %PDF header).
Encrypted PDFs raised ValueError (unsupported) Pass password=; without it, reading raises PDFEncryptedError (a ValueError).
(none) db_table / query_data(sql) over the element frame (DuckDB, in memory). DuckDB never installs extensions on its own; a query that needs one raises until you INSTALL it explicitly.
(none) Zero filesystem writes: PDFReader never writes, not even temporary files.
Hidden text wasn’t modelled Text a reader can’t see is flagged on every element (hidden_reasons), never dropped. Markdown (to_markdown(), write_markdown(), a bundle’s) leaves it out unless you pass include_hidden=True; JSON and the DataFrame keep it.

The document’s shape

4.x 5.0
{"document": {"source", "total_pages", "errors", "pages", …}} reader.document: metadata (doc_id, source_name, sha256, page_count, title, author) and pages, plus sections and header_context.
Page classification: text_only, scanned, mixed page.route: digital, hybrid, vision, and page.status. The two don’t map one to one: routing reads each page’s visible text.
Document errors: "Page N: <message>" Each failed page has status="error" and page.error; reader.document.degraded_pages lists every page whose status isn’t ok.
A page’s warnings key, only when something soft-failed page.warnings, always present (empty when nothing went wrong).
Element type: header, subheader, body_text, caption, table, image kind: heading (with a level), paragraph, key_value, table, list, figure.
id: elem_01_001 element_id: p0001_e000 (page, then 0-based reading order).
position: {x, y, w, h} bbox: {x0, y0, x1, y1}. Both are in points, top-left origin.
confidence and font metadata per element Not per element. A vision page’s page.quality.ocr_mean_confidence (0–100) holds OCR’s mean confidence.
Flattened columns id, type, page, x, y, w, h, confidence, content, metadata (JSON strings) The twelve element-row columns, typed: bbox a struct, scope_path and hidden_reasons lists.

Writing

4.x 5.0
write_json(export_filename=None, image_output_dir=None, dedupe_images=True, drop_layout_tables=False), default output.json, pretty-printed write_json(out_filepath): the document JSON (lossless, versioned, compact). The path is required; there are no default filenames.
write_json_newline_delimited(...) write_jsonl(out_filepath), one element per line. write_json_newline_delimited stays as an alias.
write_markdown(export_filename=None, image_output_dir=None, dedupe_images=True, drop_layout_tables=False) write_markdown(out_filepath, *, include_hidden=False). Hidden elements are left out unless include_hidden=True, and figures are placeholder comments; for Markdown that links its images, use write_bundle.
dedupe_images= Removed with image_output_dir=. extract_images and write_bundle write each distinct figure once.
extract_images(output_dir=None, dedupe=True) extract_images(output_dir, *, dedupe=True): one PNG per figure, named <sha256>.png when deduped (<sha256>-<its PNG's SHA-256>.png for another picture of the same stream), <element_id>.png (p0001_e003.png) otherwise.
render_pages_as_images(output_dir=None, dpi=None, image_format="png") render_pages_as_images(output_dir, *, dpi=None): 8-bit grayscale PNG only (image_format removed), through the guarded, memory-capped renderer.
(none) write_parquet(out_filepath), write_csv(out_filepath) (nested columns flattened), and write_bundle(output_dir, *, overwrite=False): an atomic, checksummed directory of JSON, JSONL, Markdown (whole and per page), images, page images, and a manifest that PDFReader reloads. A matching bundle already there is reused; overwrite=True replaces any other bundle.
Every write Atomic: a temporary file, then a rename.

Output names and return values

Method 4.x default 5.0
write_json output.json The path you pass.
write_json_newline_delimited / write_jsonl output.jsonl The path you pass.
write_markdown output.md The path you pass.
extract_images output_images/, files named report_page01_img01.png The directory you pass; files named <sha256>.png (or <sha256>-<its PNG's SHA-256>.png), or p0001_e003.png with dedupe=False.
render_pages_as_images page_images/, files named report_page_01.png The directory you pass; files named page_0001.png.
Return values The path as given, a str (or a list of them) An absolute pathlib.Path (or a list of them); write_bundle returns the PDFDocument with its file paths set.

OCR

  • pytesseract is removed. Install the tesseract program, with eng and osd data. Datagrunt runs it by an absolute path found in PATH’s absolute directories.
  • Pages route digital, hybrid, or vision. A vision page (a scan, a page of mostly unreadable characters, or one with too little text beside images or drawings) is read by OCR at vision_dpi, with orientation detection and 900 px bands.
  • Its own digital text is kept, flagged superseded.
  • Without Tesseract, a vision page’s status is ocr_unavailable, and its digital text is kept.
  • Each vision page’s render and OCR share a time budget (600 s). A page that runs out is ocr_unavailable: its digital text is kept, the words OCR read before the budget ran out are not used, and a warning says how many bands were read.

Hidden text (new)

Text a reader can’t see is never dropped: it is flagged with hidden_reasons. The reasons are:

  • off_page, invisible, tiny_font, transparent;
  • matches_background, zero_scale, duplicate;
  • superseded, unchecked_background.

Markdown leaves hidden elements out by default (include_hidden=True keeps each behind a <!-- hidden: reasons --> comment); JSON and the DataFrame keep them. Filter on hidden_reasons before passing those rows to an LLM; see Hidden text.

Errors and pages

  • A page that can’t be extracted is an error page: status="error", with its reason in page.error. The rest of the document extracts. With strict=True, it raises instead.
  • PDFEncryptedError is a DatagruntPDFError and a ValueError, so except ValueError still works. PDFCorruptError is a DatagruntPDFError and a RuntimeError, as 4.x’s PdfiumError was, so except RuntimeError still works.
  • Encrypted and unreadable PDFs raise on first use, not at construction: construction checks only the path and the options.
  • Every page reports its status (ok, ocr_fallback, ocr_unavailable, error), its route and quality, and whether its elements were verified (integrity_verified).

Concurrency

4.x 5.0
workers=1 (the default) parsed in your process Every PDF is parsed in worker processes, never in yours: one page worker with workers=1, that many with more. Figure reads and page renders run in workers too.
workers > 1 needed an if __name__ == "__main__": guard (a spawn process pool on macOS and Windows) No guard: workers are plain python -I subprocesses that never import your script. See Concurrency & Environments.
No memory cap: PDFium decoded in your process (or the pool’s), with only image-size checks up front Each worker is capped at 2 GiB. A page that passes the cap or its time bound becomes an error page with its reason, and the other pages are read by a new worker.
A frozen application (PyInstaller) could parse PDFs with workers=1, in its own process It can’t start workers, so reading a PDF raises DatagruntPDFError saying so.
PyMuPDF ignored workers One engine: workers always applies.
Spark, Beam, Flink, and Celery ran sequentially, in your process The same environment variables make one worker read the pages, whatever workers says.

Install

Python 3.11 or newer. 4.x supported Python 3.10; 5.0 needs 3.11 or newer, for every file type, not only PDF.

uv pip install "datagrunt[pdf]", which pulls in pypdfium2 and Pillow. Install the tesseract program separately for OCR. See Optional PDF Support.

Note

Applications that can’t migrate yet can pin datagrunt[pdf]<5 until they move to the 5.0 API.