Skip to main content

docpluck

PDF, DOCX, and HTML text extraction and normalization for academic papers.

Built from cross-project experience across 8,000+ PDFs spanning psychology, medicine, economics, physics, and biology. Achieves 100% accuracy on 29 manually verified ground-truth passages (see BENCHMARKS.md).

Supports three input formats:

  • PDF via pdftotext default mode (with pdfplumber SMP recovery)
  • DOCX via mammoth (DOCX → HTML → text, preserving Shift+Enter soft breaks)
  • HTML via beautifulsoup4 + lxml (block/inline-aware tree-walk)

All three formats feed into the same normalization pipeline and quality scoring.


Scope — read this before you build on the output

docpluck extracts and normalizes ENGLISH-language science articles written in US numeric convention (. decimal, , thousands).

It canonicalises NOTATION. It does not fix the paper.

Two consequences that are deliberate limits, not bugs:

  1. European numbers pass through exactly as printed. d = 0,45 stays d = 0,45; N = 1,182 keeps its comma. We do not convert them, because the source token is the only evidence you have of what the paper meant — once converted, that evidence is gone. .replace(",", "") is one line on your side and irreversible on ours.
  2. The paper's own errors reach you untouched. p = 38., p < 05 and p = 001 are what those pages actually print (each verified by rasterizing the page). Silently repairing them would launder a real defect into your analysis: you would validate a number the paper never printed, and the author would never learn. Detecting and flagging them is yours — you hold the parsed statistic and its context.

What we DO fix is damage our own pipeline caused — a glyph the text layer lost, a fused exponent, a sign-flipped interval, a < extracted as b. That is the whole distinction: did we break this, or did the paper?

Full statement, including the known limits: SCOPE.md.


Install

# PDF only (pdfplumber)
pip install docpluck

# + DOCX support (adds mammoth)
pip install docpluck[docx]

# + HTML support (adds beautifulsoup4 + lxml)
pip install docpluck[html]

# Everything
pip install docpluck[all]

System requirement for extract_pdf(): poppler-utils (provides the pdftotext binary). DOCX and HTML are pure Python — no system dependencies.

# Linux / WSL
apt-get install poppler-utils

# macOS
brew install poppler

# Windows
# Download from https://github.com/oschwartz10612/poppler-windows/releases
# Add bin/ to PATH

Install from GitHub (like R's remotes::install_github()):

pip install git+https://github.com/giladfeldman/docpluck.git

# Pinned version
pip install "docpluck>=1.3.0"

Quick Start

from docpluck import (
    extract_pdf, extract_docx, extract_html,
    normalize_text, NormalizationLevel, compute_quality_score,
)

# 1. Extract text from any supported format
with open("paper.pdf", "rb") as f:
    text, method = extract_pdf(f.read())

# Or from DOCX:
# with open("paper.docx", "rb") as f:
#     text, method = extract_docx(f.read())

# Or from HTML:
# with open("paper.html", "rb") as f:
#     text, method = extract_html(f.read())

print(f"Extracted {len(text):,} chars via {method}")

# 2. Normalize for statistical pattern matching
normalized, report = normalize_text(text, NormalizationLevel.academic)

print(f"Steps applied: {report.steps_applied}")
print(f"Changes made: {report.changes_made}")

# 3. Check quality
quality = compute_quality_score(normalized)
print(f"Quality: {quality['score']}/100 ({quality['confidence']})")
if quality["garbled"]:
    print("Warning: text may be corrupted (column merge or encoding failure)")

Structured extraction (v2.0)

For consumers that need tables and figures as structured data — meta-analysis tooling, statistical-claim extraction, dashboards — call extract_pdf_structured():

from docpluck import extract_pdf_structured

with open("paper.pdf", "rb") as f:
    result = extract_pdf_structured(f.read())

print(f"{result['page_count']} pages")
print(f"{len(result['tables'])} tables, {len(result['figures'])} figures")

for t in result["tables"]:
    print(f"  {t['label']} on page {t['page']} ({t['kind']}, confidence={t['confidence']})")
    if t["kind"] == "structured":
        print(f"    {t['n_rows']} rows × {t['n_cols']} cols")

fallbacks — what the library silently did instead (read this)

Every result carries a record of the fallback paths that fired for that document. This is the half of the story the text cannot tell you: a table Camelot detected and we discarded, a glyph repair we refused because the evidence was ambiguous, a font whose Greek letters are unreliable.

result = extract_pdf_structured(pdf_bytes)

result["fallbacks"]
# {'camelot_table_failed_table_likeness_gate': 26,
#  'symbol_font_greek_corruption_detected': 13}

result["fallback_details"]          # which font, which exception, which token
# {'symbol_font_greek_corruption_detected': {'AdvPS7DA6': 13}}

{} means every detector ran and found nothing — never "we did not look". A detector that cannot run says so explicitly (symbol_font_scan_not_run), because a silent instrument and a clean result must not look alike.

The same channel exists on the other two entry points:

text, report = normalize_text(raw, NormalizationLevel.academic)
report.fallbacks, report.fallback_details

from docpluck.render import RenderReport
rep = RenderReport()
md = render_pdf_to_markdown(pdf_bytes, report=rep)
rep.fallbacks

Batch runs get it per file in the <stem>.json sidecar.

Keys worth acting on immediately:

key meaning
symbol_font_greek_corruption_detected Greek letters in this document are unreliable — a Cronbach's α can arrive as a5(.93)
ci_upper_minus_inferred_from_containment a CI sign was inferred, not read off the page — treat as a hypothesis
w0j_mstat_sign_inferred_from_variable_name likewise
w0h_ambiguous_pairing_refused / w0m_… a repair was declined; the token is what the paper printed
camelot_table_* / region_grid_* / cells_grid_to_html_* a table or its rows were dropped by us
flatten_dropped_* a parsed statistic was dropped from the structured sidecar

Repairs are labelled by the evidence they rest on: typographic (something the renderer put on the page) is acted on; inferential (what a number ought to be) is properly your call, and where we keep such a repair it is declared here rather than applied silently. See docs/SCOPE.md.

Modes

# Default: caption-anchored fast path.
extract_pdf_structured(pdf_bytes)

# Thorough: scan every page for uncaptioned tables (slower).
extract_pdf_structured(pdf_bytes, thorough=True)

# Strip table/figure regions from `text` and replace with [Label: caption] markers.
extract_pdf_structured(pdf_bytes, table_text_mode="placeholder")

CLI

docpluck extract paper.pdf --structured > out.json
docpluck extract paper.pdf --structured --thorough --text-mode placeholder
docpluck extract paper.pdf --structured --html-tables-to ./out/

extract_pdf() (the v1 text-only path) is unchanged. New consumers opt in to the structured path; existing consumers see no behavioral change.

See an internal design doc for the full schema and design rationale.


API Reference

extract_pdf(pdf_bytes: bytes) → tuple[str, str]

Extract text from PDF bytes.

Parameters:

  • pdf_bytes — Raw PDF file content as bytes

Returns: (text, method) tuple where:

  • text — Extracted plain text. Check text.startswith("ERROR:") for failure.
  • method — Engine used:
    • "pdftotext_default" — standard extraction (fast, ~400ms)
    • "pdftotext_default+pdfplumber_recovery" — SMP fallback triggered (~9s), used when pdftotext outputs U+FFFD replacement characters (common in Nature/Cell papers using Mathematical Italic fonts)

Requires: pdftotext binary on PATH.

with open("paper.pdf", "rb") as f:
    text, method = extract_pdf(f.read())

if text.startswith("ERROR:"):
    raise RuntimeError(f"Extraction failed: {text}")

extract_docx(docx_bytes: bytes) → tuple[str, str]

Extract text from DOCX (Word) file bytes via mammoth.

Parameters:

  • docx_bytes — Raw DOCX file content as bytes

Returns: (text, method) tuple where method is always "mammoth".

How it works: DOCX is converted to HTML first (preserving Shift+Enter soft breaks as <br> tags), then passed through the same block/inline-aware tree-walk used by extract_html(). This preserves paragraph structure, headings, lists, and soft breaks — which mammoth.extract_raw_text() would lose.

Requires: pip install docpluck[docx] (adds mammoth>=1.8.0).

Known limitations:

  • OMML equations (Office Math) are silently dropped. Inline stats written as plain text survive; stats inside equation objects do not.
  • Tracked changes: only deleted paragraphs are handled minimally.
  • Memory: peak usage is ~3–5× file size.
from docpluck import extract_docx

with open("paper.docx", "rb") as f:
    text, method = extract_docx(f.read())

extract_html(html_bytes: bytes) → tuple[str, str]

Extract text from HTML file bytes via beautifulsoup4 + lxml.

Parameters:

  • html_bytes — Raw HTML file content as bytes (UTF-8 decoded with error replacement)

Returns: (text, method) tuple where method is always "beautifulsoup".

How it works: Custom tree-walk that distinguishes block from inline elements:

  • Block elements (<p>, <div>, <h1>–<h6>, <li>, <td>, etc.) get newlines before and after.
  • Inline elements (<a>, <span>, <em>, etc.) get spaces before and after — critical for preventing merged words like "ChanORCID" when adjacent inline elements have no whitespace between them.
  • Ignored tags (<script>, <style>, <meta>, <svg>, <iframe>, etc.) are decomposed before walking.

Why not BeautifulSoup.get_text(): get_text() cannot distinguish block from inline elements — it applies a uniform separator everywhere, which either merges paragraphs or inserts spurious whitespace. The BeautifulSoup maintainer has confirmed this will not be fixed. A custom tree-walk is required.

Requires: pip install docpluck[html] (adds beautifulsoup4>=4.12.0 and lxml>=5.0.0).

from docpluck import extract_html, html_to_text

# From bytes
with open("article.html", "rb") as f:
    text, method = extract_html(f.read())

# From an already-decoded string
text = html_to_text("<p>Hello <a>world</a></p>")

count_pages(pdf_bytes: bytes) → int

Count pages in a PDF using byte pattern matching. No external binary required. PDF only — returns None is not applicable for DOCX/HTML.

with open("paper.pdf", "rb") as f:
    content = f.read()
    n = count_pages(content)
print(f"{n} pages")

normalize_text(text: str, level: NormalizationLevel) → tuple[str, NormalizationReport]

Apply the normalization pipeline at the specified level.

Parameters:

  • text — Raw extracted text
  • level — NormalizationLevel.none | NormalizationLevel.standard | NormalizationLevel.academic

Returns: (normalized_text, report) tuple.

Normalization levels:

Level Steps Use when
none — You want raw text, no modifications
standard Core cleanup (S*) + document-shape cleanup (F0/H0/T0/P0/P1/W0) + recovery/ref joins (R2/R3/A7) General text processing (NLP, search indexing)
academic standard + statistical repairs (A* and W0*) Statistical pattern matching, meta-analysis
from docpluck import normalize_text, NormalizationLevel

# Raw text
text, _ = normalize_text(raw, NormalizationLevel.none)

# General cleanup
text, report = normalize_text(raw, NormalizationLevel.standard)

# Full statistical repair (recommended for academic PDFs)
text, report = normalize_text(raw, NormalizationLevel.academic)

print(report.version)          # e.g., "1.9.35"
print(report.steps_applied)    # ["S0_smp_to_ascii", "S1_encoding_validation", ...]
print(report.changes_made)     # {"ligatures_expanded": 27, "dashes_normalized": 3, ...}

NormalizationReport fields:

Field Type Description
level str Level used: "none", "standard", or "academic"
version str Pipeline version (e.g. "1.9.35")
steps_applied list[str] Step codes in order (e.g. ["S1_encoding_validation", "S3_ligature_expansion"])
changes_made dict[str, int] Character-level change counts per step

compute_quality_score(text: str) → dict

Compute extraction quality metrics.

Returns:

{
    "score": 85,                    # 0–100 composite score
    "common_word_ratio": 0.142,     # fraction of first 2000 words that are common English words
    "garbled": False,               # True if common_word_ratio < 0.02 (column merge / encoding failure)
    "confidence": "high",           # "high" (≥80), "medium" (≥50), "low" (<50)
    "details": {
        "ligatures_remaining": 0,   # count of ff/fi/fl ligature chars not yet expanded
        "garbled_chars": 0,         # count of U+FFFD replacement characters
        "non_ascii_ratio": 0.031,   # fraction of non-ASCII characters
    }
}

Interpreting the score:

Score Meaning
≥ 80 High quality — proceed with analysis
50–79 Medium — check manually if precision matters
< 50 Low — likely garbled (column merge, encoding failure, or non-English)
quality = compute_quality_score(text)

if quality["garbled"]:
    print("Extraction likely failed — skipping this paper")
elif quality["score"] < 50:
    print(f"Low quality ({quality['score']}) — verify manually")

Integration Examples

ESCIcheck / effectcheck

from docpluck import extract_pdf, normalize_text, NormalizationLevel, compute_quality_score
import re

def extract_stats(pdf_path: str) -> list[dict]:
    with open(pdf_path, "rb") as f:
        text, method = extract_pdf(f.read())

    normalized, report = normalize_text(text, NormalizationLevel.academic)

    quality = compute_quality_score(normalized)
    if quality["garbled"]:
        return []  # Skip garbled papers

    # Now apply your statistical patterns to `normalized`
    # e.g. find t-tests, F-tests, correlations, p-values
    p_values = re.findall(r'p\s*[<=>]\s*\.?\d+', normalized)
    return p_values

Scimeto / MetaESCI (batch processing)

from docpluck import extract_pdf, normalize_text, NormalizationLevel, compute_quality_score
from pathlib import Path

def process_corpus(pdf_dir: str) -> list[dict]:
    results = []
    for pdf_path in Path(pdf_dir).glob("**/*.pdf"):
        with open(pdf_path, "rb") as f:
            text, method = extract_pdf(f.read())

        if text.startswith("ERROR:"):
            results.append({"file": pdf_path.name, "error": text})
            continue

        normalized, report = normalize_text(text, NormalizationLevel.academic)
        quality = compute_quality_score(normalized)

        results.append({
            "file": pdf_path.name,
            "chars": len(normalized),
            "method": method,
            "quality": quality["score"],
            "garbled": quality["garbled"],
        })

    return results

Reproducibility receipts

A docpluck version alone does not pin extraction. pdftotext_default shells out to whatever poppler (or Xpdf) binary is on PATH; tables and layout run through camelot and pdfplumber; DOCX runs through mammoth and HTML through beautifulsoup4/lxml; and normalize_text applies unicodedata.normalize, whose behaviour is fixed by the Unicode database your CPython shipped with. Every one of those can change under a fixed docpluck SHA, so get_version_info() reports all of them:

from docpluck import get_version_info

get_version_info()
# {'version': '2.4.134',            # docpluck itself
#  'git_sha': '…',
#  'normalize_version': '1.9.56',   # in-repo pipeline versions, bumped
#  'sectioning_version': '1.2.4',   #   independently of the package version
#  'table_extraction_version': '2.4.10',
#  'python_version': '3.14.5',      # the interpreter…
#  'unicodedata_version': '16.0.0', #   …and its Unicode database
#  'pdftotext_path': 'C:/…/pdftotext.EXE',  # the exact binary extraction runs
#  'pdftotext_version': '24.08.0',  # the system binary, NOT pinned by the SHA
#  'pdftotext_engine': 'poppler',   # 'poppler' | 'xpdf' | 'unknown'
#  'poppler_version': '24.08.0',    # None when the engine is not poppler
#  'pdfplumber_version': '0.11.9',  # PDF layout channel…
#  'pdfminer_six_version': '…',     #   …and the text engine under it
#  'camelot_version': '2.0.0',      # tables…
#  'pypdfium2_version': '5.9.0',    #   …and what lattice rasterizes through
#  'opencv_version': '4.13.0.92',
#  'mammoth_version': '1.12.0',        # DOCX
#  'beautifulsoup4_version': '4.13.5', # HTML
#  'lxml_version': '6.1.1'}

Engines that are not installed report "not installed" rather than being omitted, so an absent optional extra is visible instead of invisible.

pdftotext_engine is reported separately because poppler and Xpdf differ behaviourally, not just in version number (Xpdf 4.x emits \n\n paragraph breaks where poppler emits \n), and both banner themselves as "pdftotext version N".

extract_to_dir() records the same fields on its ExtractionReport, plus per-file quality signals measured on the normalized output:

from docpluck import extract_to_dir

report = extract_to_dir(pdf_paths, out_dir="normalized_text")
report.write_receipt("normalized_text/_docpluck_receipt.json")

r = report.results[0]
r.n_replacement_chars   # U+FFFD count — a glyph was lost before docpluck saw it
r.n_greek_chars         # Greek letters; pair with n_chars_normalized for density

MetaMisCitations (URL-based)

import httpx
from docpluck import extract_pdf, normalize_text, NormalizationLevel

def extract_from_url(url: str) -> str:
    response = httpx.get(url, follow_redirects=True, timeout=30)
    response.raise_for_status()

    text, method = extract_pdf(response.content)
    normalized, _ = normalize_text(text, NormalizationLevel.academic)
    return normalized

What Gets Fixed

Standard normalization (NormalizationLevel.standard)

Artifact Example (before → after)
Null bytes "Study\x00 results" → "Study results"
Ligatures "significant" → "significant"
Unicode minus "r = −0.73" → "r = -0.73"
Soft hyphen (invisible) "signifi\u00ADcant" → "significant"
Non-breaking spaces "p\u00A0<\u00A0.001" → "p < .001"
Full-width digits "p = 0.001" → "p = 0.001"
Curly quotes "the "effect"" → "the "effect""
Hyphenation "signi-\nficant" → "significant"
Repeated headers Journal name repeated on every page → stripped
Page numbers Standalone 12 on its own line → stripped

Academic normalization adds (NormalizationLevel.academic)

Artifact Example (before → after)
Stat line breaks "p =\n.001" → "p = .001"
Dropped decimals "p = 484" → "p = .484"
European decimals "p = 0,05" → "p = 0.05"
CI delimiters "[0.81; 1.92]" → "[0.81, 1.92]"
Greek letters "η² = 0.12" → "eta2 = 0.12"
Superscripts "r² = 0.54" → "r2 = 0.54"
Footnote markers "p < .001¹" → "p < .001"

System Requirements

Requirement Version Notes
Python ≥ 3.10
pdfplumber ≥ 0.11.0 Core pip dependency — installed automatically
poppler-utils any recent System package — for extract_pdf() only
mammoth ≥ 1.8.0 Optional ([docx]) — pure Python, no system deps
beautifulsoup4 ≥ 4.12.0 Optional ([html]) — pure Python
lxml ≥ 5.0.0 Optional ([html]) — has prebuilt wheels

The normalization and quality functions (normalize_text, compute_quality_score) have no system requirements — pure Python, no external binaries. DOCX and HTML extraction are pure Python too; only PDF needs a system binary.


License

MIT. See LICENSE.

Citation

If you use docpluck in research, please cite:

Feldman, G. (2026). docpluck: PDF text extraction and normalization for academic papers.
https://github.com/giladfeldman/docpluck

Metadata

Release files for docpluck 2.4.135

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for docpluck 2.4.135
File Size Uploaded
docpluck-2.4.135.tar.gz 1.4 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for docpluck 2.4.135
File Interpreter ABI Platform
docpluck-2.4.135-py3-none-any.whl Python 3 none any Details

Total release size: 1.9 MB

Release files / docpluck-2.4.135.tar.gz

Download URL docpluck-2.4.135.tar.gz
Size 1.4 MB
Tags Source
SHA-256 checksum
How to use checksums
6473f946d18db1a680a116ca23c7d251e337e91303b8d63a22d68a72c6ad9595
BLAKE2b-256 checksum
How to use checksums
3a076a667e2195f7078dc5d63d4a70e3f023192678126a221e99cdf84428fd5f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.

Transparency log

Release files / docpluck-2.4.135-py3-none-any.whl

Download URL docpluck-2.4.135-py3-none-any.whl
Size 507.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d3748df12732898138669f0530c8beb8f6a32083ada2cb29b8458ae9ec8d022b
BLAKE2b-256 checksum
How to use checksums
dc4517541bf4d1bd769553c31843a7a24ead2df74656f4421a4d9ed4c8ffaa42
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2.4.135 This release

2 release files

2.4.99

2 release files

2.4.98

2 release files

2.4.97

2 release files

2.4.96

2 release files

2.4.95

2 release files

2.4.94

2 release files

2.4.92

2 release files

2.4.91

2 release files

2.4.90

2 release files

2.4.88

2 release files

2.4.85

2 release files

2.4.84

2 release files

2.4.77

2 release files

2.4.76

2 release files

2.4.75

2 release files

2.4.73

2 release files

2.4.72

2 release files

2.4.69

2 release files

2.4.68

2 release files

2.4.66

2 release files

2.4.65

2 release files

2.4.64

2 release files

2.4.63

2 release files

2.4.62

2 release files

2.4.61

2 release files

2.4.60

2 release files

2.4.59

2 release files

2.4.58

2 release files

2.4.57

2 release files

2.4.56

2 release files

2.4.55

2 release files

2.4.54

2 release files

2.4.53

2 release files

2.4.52

2 release files

2.4.51

2 release files

2.4.50

2 release files

2.4.49

2 release files

2.4.48

2 release files

2.4.47

2 release files

2.4.46

2 release files

2.4.45

2 release files

2.4.44

2 release files

2.4.43

2 release files

2.4.42

2 release files

2.4.41

2 release files

2.4.40

2 release files

2.4.39

2 release files

2.4.38

2 release files

2.4.37

2 release files

2.4.36

2 release files

2.4.35

2 release files

2.4.34

2 release files

2.4.33

2 release files

2.4.32

2 release files

2.4.31

2 release files

2.4.30

2 release files

2.4.29

2 release files

2.4.28

2 release files

2.4.27

2 release files

2.4.26

2 release files

2.4.25

2 release files

2.4.24

2 release files

2.4.23

2 release files

2.4.22

2 release files

2.4.21

2 release files

2.4.20

2 release files

2.4.19

2 release files

2.4.18

2 release files

2.4.17

2 release files

2.4.16

2 release files

2.4.15

2 release files

2.4.14

2 release files

2.4.13

2 release files

2.4.12

2 release files

2.4.11

2 release files

2.4.10

2 release files

2.4.9

2 release files

2.4.8

2 release files

2.4.7

2 release files

2.4.6

2 release files

2.4.5

2 release files

2.4.4

2 release files

2.4.3

2 release files

2.4.2

2 release files

2.4.1

2 release files

2.4.0

2 release files

2.3.1

2 release files

2.3.0

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.6.0

2 release files

1.5.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page