Salih Küçükkayış · Istanbul

← Writing ·

My PDF converter turned every borderless table into loose numbers. The fix runs on a CPU.

Document to Markdown is an Apify Actor I published this week. You give it links to PDFs, Word, PowerPoint or Excel files and get Markdown back, ready for an LLM: headings, lists, page markers and, optionally, chunks for a RAG pipeline with the page number and the nearest heading on each one.

The first version handled tables the way most text extraction does, which is to say it did not. I only found out how badly when I put it next to the competition.

Four files, four tools

I picked four public test PDFs that people who build PDF tools already use to embarrass them:

  • a report page with a borderless table: horizontal rules only, a header that spans columns;
  • a dense budget table, nine columns, no lines at all;
  • a scanned two-column page with bullet points;
  • a two-column article with a real table on its last page.

Then I ran the same four files through my Actor and three others in the Apify Store, each once, from Apify’s free credit.

My converter lost on the two things that mattered. On both tables the cells came out one per line, so the budget became a long column of loose numbers. A model reading that cannot tell which number belongs to which row. The scanned page came out with “¢” where the bullets were, and every paragraph broken at the end of each scanned line.

The best result came from an ML-based converter with OCR switched on. Its tables were clean Markdown. It also took 13 minutes and 5 seconds for the four files and cost $0.148. One page of OCR alone took about eight minutes.

That is a fine trade for a handful of important documents. It is not one for a pipeline that ingests a few thousand reports a month.

What closed the gap

Borderless tables. Text in a PDF has coordinates. When numbers line up in the same horizontal positions down a page, they are a table, lines or no lines. Camelot’s “stream” mode (MIT licensed) finds those columns from the spacing alone, in 0.1 to 0.3 seconds a page on one CPU.

The catch is that a two-column article is also text in two aligned columns, so stream mode happily turns prose into a two-column “table”. I only run it on pages that look numeric, and I throw away anything with fewer than three columns, too few numbers, or cells full of long sentences. The two-column article now stays prose, and the real table on its last page is still found.

Rows a PDF packs into one cell. Some generators put a whole column into one text box with line breaks. Those are split back into rows when there are at least three lines or they are mostly numbers, so a two-line header stays one header.

Scanned pages. Tesseract already knows which words form a line, a paragraph and a block. Rebuilding the Markdown from that instead of from raw lines fixed the broken paragraphs, and mapping the characters Tesseract reads bullets as (“¢”, “©”, “•” and friends) to - fixed the lists. Lines much taller than the page’s median become headings.

After the changes, on the same four files: three tables on the two-page borderless report, the budget as one nine-column table with the same rows as the ML converter, the scanned page with its list and headings. All of it took 23 seconds on one CPU, 21 of them OCR. On Apify, the four files took 16 seconds and $0.001 of compute.

It is not as good as the ML converter everywhere. A header cell that spans columns comes out as empty cells next to it, and a table inside a scanned page comes out as text. But it is close on the tables that matter, and it is priced at $2 per 1,000 pages ($5 per 1,000 pages that need OCR), with no per-run file limit on any Apify plan, the free one included.

Using it from n8n

The simplest way in is the Apify API’s run-sync-get-dataset-items endpoint: one HTTP request that runs the Actor, waits, and returns the results.

  1. Copy the workflow below and paste it onto an empty n8n canvas (Ctrl/Cmd+V).
  2. Create a Header Auth credential: name Authorization, value Bearer YOUR_APIFY_TOKEN.
  3. Put your file URLs in the Run Actor node and run it.

A Code node turns every chunk into one n8n item, with what you need to cite the source:

{ "text": "...", "source": "https://.../report.pdf", "pageStart": 4, "pageEnd": 5, "heading": "2.1 Results" }

Connect that to an embeddings node and a vector store, and keep source and pageStart as metadata, so an answer can say which page it came from.

The n8n workflow (JSON)
{
  "name": "PDF and Office files → Markdown RAG chunks (Apify Document to Markdown)",
  "nodes": [
    { "parameters": {}, "name": "Start", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [0, 0] },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/saliga~document-to-markdown/run-sync-get-dataset-items",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{\n  \"urls\": [\n    \"https://raw.githubusercontent.com/microsoft/markitdown/main/packages/markitdown/tests/test_files/test.pdf\",\n    \"https://raw.githubusercontent.com/microsoft/markitdown/main/packages/markitdown/tests/test_files/test.docx\"\n  ],\n  \"chunkSize\": 1500,\n  \"chunkOverlap\": 200,\n  \"extractTables\": true\n}",
        "options": { "timeout": 310000 }
      },
      "name": "Run Actor", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [220, 0]
    },
    {
      "parameters": {
        "jsCode": "// One n8n item per RAG chunk, with the metadata you need for citations.\nconst out = [];\nfor (const doc of $input.all()) {\n  for (const c of doc.json.chunks ?? []) {\n    out.push({ json: { text: c.text, source: doc.json.url, title: doc.json.title ?? doc.json.fileName,\n      pageStart: c.pageStart, pageEnd: c.pageEnd, heading: c.heading, chunk: c.index } });\n  }\n}\nreturn out;"
      },
      "name": "One item per chunk", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [440, 0]
    }
  ],
  "connections": {
    "Start": { "main": [[{ "node": "Run Actor", "type": "main", "index": 0 }]] },
    "Run Actor": { "main": [[{ "node": "One item per chunk", "type": "main", "index": 0 }]] }
  },
  "settings": { "executionOrder": "v1" }
}

Without n8n it is one request:

curl -X POST "https://api.apify.com/v2/acts/saliga~document-to-markdown/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" -H "Content-Type: application/json" \
  -d '{"urls": ["https://example.com/report.pdf"], "chunkSize": 1500}'

An AI agent can use it the same way through the Apify MCP server: it passes file URLs and gets Markdown and chunks back.

The lesson I keep relearning: my own test files said the converter was fine. Four files someone else picked to be hard said otherwise in an afternoon.