Extract PDF multi-column text in correct reading order
Four shuffled blocks from a two-column page come back as left first, left second, right first and right second. Columns are found from similar left edges, then read left to right and top to bottom, and each placement stays visible for checking. Free in your browser.
- Free
- Runs in your browser
- No sign-up
- Your data stays on your device
Run — free
PDF text extraction often returns accurate words in an unusable sequence. On a page with two or more columns, a simple top-to-bottom sort can alternate between unrelated paragraphs. This capability accepts page text blocks with their bounding boxes, identifies blocks that share a column, orders those columns from left to right, and then orders each column from top to bottom. The result includes reconstructed text plus the complete ordered geometry for inspection or further processing.
Why PDF extraction loses reading order
A PDF normally describes where glyphs are painted, not the semantic order in which a person should read them. Extraction libraries can therefore return blocks in object-stream order, drawing order, or a global vertical order that jumps between columns. A two-column article may come back as the first paragraph on the left, the first paragraph on the right, then the second paragraph on the left.
Supply each extracted block with its text, left coordinate, top coordinate, width, and height. This capability uses that geometry to infer groups with similar left edges. It then places the inferred columns from left to right and sorts their blocks from top to bottom.
All coordinates must use the same page coordinate system, with `y` representing the top of a block.
How column detection stays deterministic
Column membership is based on the distance between block left coordinates. By default, the tolerance is derived as one quarter of the median positive block width. That adapts to common page scales while allowing small indentation and extraction noise inside a column.
A smaller value separates blocks whose left edges differ slightly; a larger value combines indented paragraphs or headings with their surrounding column. After grouping, columns are ordered by their average left position. Blocks inside each column are ordered by top position, then left position, then their original array index.
Those tie-breakers matter when boxes share coordinates and guarantee that identical input always produces identical output. The algorithm uses no network calls, random values, current time, OCR, or language model.
What you can do with it
Prepare articles for search
Turn two-column journal or newsletter blocks into coherent text before indexing them.
Build accessible exports
Create a predictable sequence for screen-reader text or accessible document conversion.
Audit an extraction pipeline
Retain ordered bounding boxes and source indices to explain exactly how every paragraph was placed.
FAQ
Does this capability accept a PDF file?
No. It accepts text blocks that have already been extracted from one PDF page, including their bounding boxes.
How are columns detected?
Blocks with sufficiently similar left coordinates are grouped together.
What does it cost?
The same deterministic logic can also run in the browser as a tier-A capability.
Can I send several pages at once?
Send one page at a time. Positions from different pages cannot be compared unless you first bring them to a common scale yourself, so mixing pages would scramble the columns.
Does it recognize tables, sidebars or footnotes?
No. It orders text by column and by height on the page only. Tables, sidebars, headlines spanning several columns and footnotes are not guessed, so mark them beforehand or adjust the column tolerance and check the result.
Related tools
For developers — API access
Everything on this page is available programmatically. This section is for teams who want to wire it into their own systems; everyone else can just use the tool above.
API endpoint
Prefer to automate it? One authenticated POST creates the task; the result comes back by webhook or a signed link. The same capability also runs here on the web, by email and from Telegram — and soon from our app too.
Call it from your stack
curl -X POST https://api.kit.forhosting.com/pdf/text-extract-column-order \
-H "Authorization: Bearer $KIT_KEY" \
-H "Content-Type: application/json" \
-d '{"blocks":[{"text":"Left heading","x":40,"y":40,"width":210,"height":20},{"text":"Right heading","x":320,"y":42,"width":210,"height":20},{"text":"Right body","x":322,"y":90,"width":208,"height":60},{"text":"Left body","x":42,"y":92,"width":208,"height":60}]}'const res = await fetch("https://api.kit.forhosting.com/pdf/text-extract-column-order", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.KIT_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"blocks": [
{
"text": "Left heading",
"x": 40,
"y": 40,
"width": 210,
"height": 20
},
{
"text": "Right heading",
"x": 320,
"y": 42,
"width": 210,
"height": 20
},
{
"text": "Right body",
"x": 322,
"y": 90,
"width": 208,
"height": 60
},
{
"text": "Left body",
"x": 42,
"y": 92,
"width": 208,
"height": 60
}
]
})
});
const { task_id } = await res.json();import os, requests
res = requests.post(
"https://api.kit.forhosting.com/pdf/text-extract-column-order",
headers={"Authorization": f"Bearer {os.environ['KIT_KEY']}"},
json={
"blocks": [
{
"text": "Left heading",
"x": 40,
"y": 40,
"width": 210,
"height": 20
},
{
"text": "Right heading",
"x": 320,
"y": 42,
"width": 210,
"height": 20
},
{
"text": "Right body",
"x": 322,
"y": 90,
"width": 208,
"height": 60
},
{
"text": "Left body",
"x": 42,
"y": 92,
"width": 208,
"height": 60
}
]
},
)
task_id = res.json()["task_id"]<?php
$res = file_get_contents("https://api.kit.forhosting.com/pdf/text-extract-column-order", false, stream_context_create([
"http" => [
"method" => "POST",
"header" => "Authorization: Bearer " . getenv("KIT_KEY") . "\r\nContent-Type: application/json",
"content" => '{"blocks":[{"text":"Left heading","x":40,"y":40,"width":210,"height":20},{"text":"Right heading","x":320,"y":42,"width":210,"height":20},{"text":"Right body","x":322,"y":90,"width":208,"height":60},{"text":"Left body","x":42,"y":92,"width":208,"height":60}]}',
],
]));
$task = json_decode($res, true);body := bytes.NewBufferString(`{"blocks":[{"text":"Left heading","x":40,"y":40,"width":210,"height":20},{"text":"Right heading","x":320,"y":42,"width":210,"height":20},{"text":"Right body","x":322,"y":90,"width":208,"height":60},{"text":"Left body","x":42,"y":92,"width":208,"height":60}]}`)
req, _ := http.NewRequest("POST", "https://api.kit.forhosting.com/pdf/text-extract-column-order", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KIT_KEY"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)Example request
{
"blocks": [
{
"text": "Left heading",
"x": 40,
"y": 40,
"width": 210,
"height": 20
},
{
"text": "Right heading",
"x": 320,
"y": 42,
"width": 210,
"height": 20
},
{
"text": "Right body",
"x": 322,
"y": 90,
"width": 208,
"height": 60
},
{
"text": "Left body",
"x": 42,
"y": 92,
"width": 208,
"height": 60
}
]
}Example response
{
"task_id": "tsk_a1b2c3d4e5f6a1b2c3d4e5f6",
"type": "pdf.text_extract_column_order",
"status": "queued",
"_links": {
"result": "/tasks/tsk_…/result"
}
}The API is asynchronous: the call returns a task_id immediately and the result arrives by webhook. Polling is capped at 1 req/s per task.
Pricing
Published price — no tokens, no invented credits. A failed task is never charged.
Limits
max_items | 10000 |
Errors
| HTTP | Code | Meaning |
|---|---|---|
401 | unauthorized | Missing or invalid API key. |
402 | insufficient_balance | Your balance doesn't cover the task price. |
404 | unknown_type | That task type doesn't exist. |
429 | rate_limited | Too many requests. Use the webhook instead of polling. |
Technical notes
Why PDF extraction loses reading order
The returned `reading_order` preserves the original geometry and source index, so you can verify every placement rather than trusting an opaque text-only transformation.
How column detection stays deterministic
For documents with unusual geometry, set `column_tolerance` explicitly.
Using the ordered result in a document pipeline
Keep the structured arrays when you need provenance, highlighting, page overlays, or debugging; use the joined text for search indexing, summarization, accessibility processing, or export. The response provides several useful views of the same decision. `columns` exposes each detected column and its ordered blocks. `reading_order` flattens those columns into the exact sequence a downstream process should consume. `text` joins the block texts with a configurable separator, which defaults to a blank line so paragraph boundaries remain visible. Process one PDF page per request because coordinates from different pages are not comparable unless you normalize them yourself. This capability deliberately does not parse PDF files or perform OCR: feed it blocks from your existing extractor. It also does not guess tables, sidebars, spanning headlines, footnotes, or cross-page continuation. When those structures matter, preclassify them or provide a carefully chosen `column_tolerance` and inspect the returned column assignments.
Technical questions
What happens when no blocks are provided?
The request fails with an `invalid_input` error because reading order cannot be inferred without text blocks.
What coordinate system should I use?
Any consistent page coordinate system works as long as `x` is the left coordinate and `y` is the top coordinate for every block.