textreflow
Layout regions in, ordered prose out. A layout detector (Docling, YOLO, Surya, LayoutParser, PaddleOCR, …) tells you where the title, the paragraphs and the tables are on each page. It does not tell you the order to read them in, or that the paragraph cut off at the bottom of the left column continues at the top of the right one. textreflow is the step in between:
PDF ──▶ layout detector ──▶ regions ──▶ textreflow ──▶ typed blocks ──▶ chunker
- Column-aware reading order. Full-width bands (title, abstract, wide figures) first, then the left column top to bottom, then the right.
- Paragraphs rebuilt across regions, columns and page breaks. A paragraph stays open until a sentence ends, and a figure, caption or footnote in between does not close it.
- De-hyphenation from the document itself.
construc-+tionbecomesconstruction, butEdge-+centricstaysEdge-centricwhen that compound appears unbroken somewhere else in the same document. - Structure kept as block types: title, section, authors, caption, table, formula, footnote, list.
- Page furniture removed from the flow (running heads, page numbers, text inside figures) and returned separately, not thrown away.
- No dependencies. Standard library only. No detector is bundled, so nothing AGPL comes along with it.
pip install textreflow
Use
from textreflow import assemble, render_txt
layout = {"pages": [{"page": 0, "width": 612, "regions": [
{"label": "Text", "bbox": [40, 100, 290, 700], "lines": ["Models have demon-"]},
{"label": "Text", "bbox": [320, 60, 570, 200], "lines": ["strated it."]},
]}]}
blocks, dropped = assemble(layout)
blocks # [{"type": "paragraph", "page": 0, "text": "Models have demonstrated it."}]
dropped # {"page_headers": [], "page_footers": [], "picture_text": []}
print(render_txt(blocks)) # tagged text: "# " title, "## " section, [TABLE] … [/TABLE], …
From Docling (install it yourself; textreflow only reads the document object):
from docling.document_converter import DocumentConverter
from textreflow import assemble
from textreflow.adapters.docling import from_docling
doc = DocumentConverter().convert("paper.pdf").document
blocks, dropped = assemble(from_docling(doc))
The blocks are exactly the input that grain-growth-chunking takes, so the two chain directly:
from grain_growth import chunk_document
chunks, skipped = chunk_document(blocks, filename="paper")
The input contract
For each page: its number, its width, and the regions found on it. A region has a label, a bounding box, and its text as lines.
{"pages": [
{"page": 0, # 0-based
"width": 612.0, # same units as bbox
"regions": [
{"label": "Text",
"bbox": [40.0, 100.0, 290.0, 200.0], # x0, y0, x1, y1, top-left origin
"lines": ["The construc-", "tion of graphs"]}],
"swallowed_text": [ # optional
{"label": "Picture", "text": "axis"}]}]}
label |
Becomes |
|---|---|
Title |
title block |
Section-header |
section block; closes the open paragraph |
Authors |
authors block; read on its own, never as part of a column |
Text |
prose, merged across regions until a sentence ends |
List-item |
consecutive items form one list block |
Caption, Footnote, Formula |
their own blocks; an open paragraph continues past them |
Table |
table block, one row per line |
Picture |
nothing; a position anchor only |
Page-header, Page-footer |
removed from the flow, returned in dropped |
Regions can arrive in any order, because reading order is worked out from the
boxes. Any unit works (PDF points, pixels) as long as bbox and width use
the same one. lines must already be in reading order within the region:
grouping words into lines is the detector's job. Put text found inside
pictures in swallowed_text if you want it reported in dropped. Other keys
(conf, height, …) are ignored.
Check a layout before you trust the output. A wrong label does not crash anything: the region is quietly read as prose.
from textreflow import validate_layout
validate_layout(layout) # raises ValueError on the first problems
validate_layout(layout, strict=False) # returns every problem as a list
Column geometry can be tuned: assemble(layout, full_width_fraction=0.6, single_column_fraction=0.7). The defaults are the values used in the
evaluation below.
Evidence
The measure is the share of paragraphs (over 40 characters) that begin with a lower-case letter. Each of these is a continuation the parser failed to join to the paragraph before it. Measured on 60 arXiv papers:
| Pipeline | Paragraphs | Begin mid-sentence |
|---|---|---|
| Docling, its own document model | 4,378 | 12.5% |
| Docling's layout → textreflow | 3,031 | 2.4% |
| A fine-tuned YOLO layout → textreflow | 3,022 | 4.8% |
Same detector, same boxes: replacing only Docling's assembly step with textreflow cuts mid-sentence paragraphs about five-fold, from 12.5% to 2.4%.
Source: evals/Reports/parser_quality_60.json
in the Anneal repository, produced by evals/scripts/parser_quality_probe.py.
What this does not show:
- These are pooled percentages over all paragraphs. They are not a paired per-paper test and have no confidence interval, so the size of the effect per document is not established.
- An earlier 5-paper run gave 17.4% against 2.9%. That smaller sample overstated Docling's failure rate. Use the 60-paper figures above.
- The metric catches only missed merges. A wrong merge (two paragraphs
joined because the first did not end in
.,!or?) does not show up in it. - The heuristics were developed on arXiv-style one- and two-column papers. Three-column layouts, right-to-left scripts and forms have not been evaluated.
Development
pip install -e ".[test]"
pytest -q # 40 tests, no network, no detector needed
Citing
If this is useful in your work, please cite it. CITATION.cff in this
repository gives GitHub's "Cite this repository" button everything it needs:
Choudhary, M. (2026). textreflow: reading order and paragraph reconstruction for document layout detectors (version 1.0.0) [Software]. https://github.com/mudit-choudhary/textreflow
ORCID: 0009-0003-8880-0925.
Licence
Metadata
Release files for textreflow 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| textreflow-1.0.0.tar.gz | 23.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| textreflow-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 41.3 kB
Release files / textreflow-1.0.0.tar.gz
| Download URL | textreflow-1.0.0.tar.gz |
|---|---|
| Size | 23.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8680dda5218d699a7ac790fc94157110b6c6ccba6749ec7226b69bc6a8cd0119
|
|
BLAKE2b-256 checksum How to use checksums |
50cf5f1d0a58a3ab95f882cd0d985985bdaa18066f6a0c3cb0c9e2d01b0d49ab
|
| 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 Sep 21, 2026.
Transparency logRelease files / textreflow-1.0.0-py3-none-any.whl
| Download URL | textreflow-1.0.0-py3-none-any.whl |
|---|---|
| Size | 18.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2b5fd4b5e9bf01e8ee0622029ba17805f678d5e772e5bc7a33205e01d33cbb56
|
|
BLAKE2b-256 checksum How to use checksums |
5dcab76d7539c7afbfbedc2d80eceb77b378ac63160cb204bdeda43cb20c3482
|
| 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 Sep 21, 2026.
Transparency log