Spaces:
Running
pdf_pipeline
A PDF β Markdown extraction pipeline implementing this flow:
pdf
ββ pdfplumber βββββββββββββββ¬βββββββββββββββββββββββββββββββ
β words+bboxes(xyxy) β image β
βΌ βΌ β
empty words? pp-doclayout β
yes β no (DocLayoutV3) β
β ββββββββββββββ β layout_class+bbox+order β
βΌ βΌ βΌ
OCR(pytesseract/ Plumber words + pp-doclayout matching
paddleocr/rapidocr) (align_words_to_layout)
β β
βΌ βΌ
Matching ocr bboxes (class_name, bbox, reading_order, text)
and layout β
(align_ocr_to_layout) βΌ
β class_name == "table"?
β yes β no
βββββββββββββ¬ββββββββββββ ββ formatted by class
βΌ
TableFormerONNX β OTSL β markdown
Both matching branches converge on the same RegionText shape and the same
downstream per-region rendering (region_to_markdown), including the
table β TableFormerONNX β OTSL β markdown branch β the diagram only draws
that branch coming off the pdfplumber-matching box, but a "table" layout
region can equally appear on a scanned (OCR'd) page, so it's handled the
same way in both paths.
Modules
| File | Diagram piece(s) |
|---|---|
layout.py |
pp-doclayout (DocLayoutV3 ONNX wrapper) + both "matching" boxes (align_words_to_layout, align_ocr_to_layout, sharing the generic align_tokens_to_layout core) |
ocr_backends.py |
Ocr(pytesseract, paddleocr, rapidocr) β also reused for table-cell OCR |
table_extraction.py |
TableFormerONNX β OTSL β markdown |
pipeline.py |
The if the words are empty branch, the if classname is table branch, and final markdown assembly (process_pdf_page, process_pdf) |
What's new vs. the two source notebooks
The two original notebooks (pdfplumber_.ipynb, docling_.ipynb) already
had working code for: layout detection, the pdfplumberβlayout matcher, OCR
backends, and TableFormerβOTSL. What was missing, per the diagram, was:
- The OCR-fallback matching box.
align_words_to_layoutonly knew how to consume pdfplumber'sextract_words()output.align_tokens_to_layoutgeneralizes the matching algorithm (containment + nearest-centroid fallback + line re-assembly) to take anytext + bboxtoken list, in image-pixel space.align_words_to_layoutandalign_ocr_to_layoutare now both adapters onto that one function. - The
if classname is tablebranch wired into the page loop. The notebook's per-region markdown loop only ever embedded a cropped image fortable/image/chartregions; TableFormer was only run manually, separately, against one standalone table image.region_to_markdownnow checksclass_name == "table"and runstable_image_to_otsl+otsl_to_markdownautomatically, falling back to an embedded image if noTableFormerONNXrunner was supplied (or if extraction throws). - Table cells prefer pdfplumber's own words over OCR. When the PDF has a
real text layer, the words pdfplumber already extracted for a table region
are reused directly as TableFormer's cell-text source (
tokens=onocr_anchor_cells/table_image_to_otsl) instead of re-OCR-ing the table crop β cheaper and avoids OCR mistakes on text that's already exact.table_ocr_backendis now only invoked as a fallback when a table region has no underlying words (e.g. the table is itself a scanned image, or the whole page is scanned and went through the page-level OCR branch). - Standalone image input.
process_image_page/process_imagesrun the same diagram on plain images (.jpg/.png/etc. β a photographed or scanned page with no PDF structure at all), always via the OCR branch since there's no native text layer to check.process_documentis a single dispatcher that picks the PDF path or the image path based on the input, so callers don't need to branch on file type themselves. Both share the same per-image core (_process_rendered_page) as the PDF path, so a scanned PDF page and a standalone photo of a page are handled identically once OCR kicks in. - Fixed a latent color-channel bug along the way.
DocLayoutV3._preprocessalways applies a BGRβRGB swap, correct only if its input really is BGR (ascv2.imreadproduces). The original page-rendering code fed it a PIL-derived array (RGB) directly, which silently double-swapped the channels and degraded detection on every PDF page processed through this pipeline. Fixed via a shared_to_bgr_arrayhelper used by both the PDF and image entry points. otsl_to_markdown. The OTSLβDocTagsDocumentβDoclingDocumentβmarkdown steps were ad hoc, later notebook cells, applied to one hardcodedotslvariable. Factored into one function so it can run once per detected table inside a page loop.
Requirements
pip install pdfplumber numpy opencv-python onnxruntime pydantic pillow docling-core
# plus whichever OCR backend(s) you use:
pip install pytesseract # needs the tesseract binary too
pip install rapidocr-onnxruntime
pip install paddleocr paddlepaddle
You'll also need the model artifacts referenced in the original notebooks:
PP-DocLayout/PP-DocLayoutV3.onnxtableformerv1/onnx/<variant>/tableformer_<variant>_{encoder,decoder_step,bbox_decoder}.onnxtableformerv1/tm_config.json
Usage
from pdf_pipeline import DocLayoutV3, TableFormerONNX, get_ocr_backend, process_document
layout_detector = DocLayoutV3("PP-DocLayout/PP-DocLayoutV3.onnx")
page_ocr_backend = get_ocr_backend("rapidocr") # used on scanned PDF pages AND any standalone image
table_runner = TableFormerONNX(artifact_root="tableformerv1")
table_ocr_backend = get_ocr_backend("paddleocr") # fallback for tables with no underlying text
# Works the same regardless of input type:
markdown_doc = process_document(
"document.pdf", # or "scan.jpg", or ["page1.png", "page2.png"]
layout_detector,
page_ocr_backend=page_ocr_backend,
table_runner=table_runner,
table_ocr_backend=table_ocr_backend,
)
with open("document.md", "w") as f:
f.write(markdown_doc)
process_document dispatches on what you pass it: a .pdf path goes
through process_pdf (pdfplumber, may have a native text layer); a single
image path (.jpg/.png/etc.) or a list of image paths goes through
process_image_page/process_images (always OCR, since standalone images
never have a text layer). Call process_pdf/process_image_page directly
if you want the type-specific kwargs (e.g. pages= or resolution= only
make sense for PDFs) or the full per-page debug dict instead of just the
markdown string.
For single-page debugging (mirrors the original notebook's main cell), use
process_pdf_page(page, layout_detector, doc_path, ...) directly β it
returns a dict with markdown, regions, layout_result, image, and
used_ocr, so you can inspect intermediate state (e.g. call
layout_detector.visualize(result["layout_result"], "debug.png", result["image"]))
before trusting the final markdown.
Cropped images (for image/chart/table-without-TableFormer regions) are
written to <doc_stem>/images/<n>.png, same convention as the original
notebook's crop_and_save_image.
Notes / things to tune for your documents
containment_threshold/line_tol_ratio(passed throughprocess_pdf_pageβalign_words_to_layout/align_ocr_to_layoutβalign_tokens_to_layout) control how aggressively tokens are assigned to boxes and how lines are re-grouped; the defaults (0.5 / 0.5) come from the original notebook.SKIP_CLASSESandIMAGE_LIKE_CLASSESinpipeline.pycontrol which layout classes are dropped vs. rendered as a markdown image link vs. rendered as text β extend these for your document types (e.g. treatdisplay_formula/inline_formulaspecially instead of falling through to plain text).- If
table_runner/table_ocr_backendare omitted, table regions degrade gracefully to an embedded cropped image instead of raising.