What it does
- Reads the PDF's text layer and rebuilds structure: headings from font size, numbering and ALL-CAPS lines; aligned rows as Markdown pipe tables; ligatures (fi, fl) expanded to plain letters.
- Accepts a multipart upload, base64, a URL Sigli fetches, or an upload ID. Password-protected PDFs work with
password. - In our test run, a 1.5 MB, 32-page standards document (NIST CSF 2.0) converted inline in about 1.1 s into 68,577 characters of Markdown.
curl
API=${SIGLI_API_URL:-https://api.sigli.dev}
curl -sS "$API/v1/process" \
-H "X-API-Key: $SIGLI_API_KEY" \
-H "X-Convert-Type: pdf-to-markdown" \
-F "file=@nist_cswp29.pdf"
{
"success": true,
"meta": { "type": "pdf-to-markdown", "engine": "inhouse" },
"data": {
"markdown": "# The NIST Cybersecurity Framework (CSF) 2.0\n\n…",
"processing_ms": 1130,
"input_size_bytes": 1518858,
"output_size_bytes": 68577
}
}Python
Files over 2 MB come back as HTTP 202 with a job_id; this script polls the job and reads markdown from it either way.
# Convert a PDF with a text layer to Markdown. Files over 2 MB are queued
# as a job (HTTP 202); poll it and read the markdown from the job.
# pip install requests
import os
import sys
import time
import requests
API = os.environ.get("SIGLI_API_URL", "https://api.sigli.dev")
HEADERS = {"X-API-Key": os.environ["SIGLI_API_KEY"]}
path = sys.argv[1] if len(sys.argv) > 1 else "nist_cswp29.pdf"
with open(path, "rb") as f:
resp = requests.post(
f"{API}/v1/process",
headers={**HEADERS, "X-Convert-Type": "pdf-to-markdown"},
files={"file": (os.path.basename(path), f, "application/pdf")},
timeout=120,
)
if resp.status_code == 422 and resp.json().get("code") == "no_text_layer":
raise SystemExit("Scanned PDF: use X-Convert-Type: ocr instead")
resp.raise_for_status()
data = resp.json()["data"]
if resp.status_code == 202:
job_id = data["job_id"]
while data.get("status") not in ("completed", "failed"):
time.sleep(3)
job = requests.get(f"{API}/v1/jobs/{job_id}", headers=HEADERS, timeout=30)
job.raise_for_status()
data = job.json()["data"]
if data["status"] == "failed":
raise SystemExit(f"{data['code']}: {data['error']}")
out = os.path.splitext(path)[0] + ".md"
with open(out, "w", encoding="utf-8") as f:
f.write(data["markdown"])
print(f"{out}: {len(data['markdown'])} chars in {data['processing_ms']} ms")
Scanned PDFs: 422 no_text_layer
An image-only PDF has no text to extract. Instead of returning empty Markdown, the API answers HTTP 422 with "code": "no_text_layer" and charges nothing. Send the same file to the OCR API (X-Convert-Type: ocr, output_format=markdown) — the Python OCR guide shows the fallback in code.
Pricing and limits
- 3 credits per started 5 MB of input; failed requests are free. Included from the Starter plan ($29/month, 3,000 credits); new workspaces get a 14-day Starter trial.
- Up to 50 MB per file. Over 2 MB runs as an async job — poll
GET /v1/jobs/{job_id}. - Tables are rebuilt from aligned text; merged or multi-line cells can come out split, so review tables before relying on them.
- The reverse direction, HTML into a PDF, is the HTML to PDF API.
FAQ
Does it work on scanned PDFs?
Not directly. A PDF without a text layer returns 422 with code no_text_layer and no credits are charged. Send the same file with X-Convert-Type: ocr to get its text.
How are tables handled?
Rows of aligned text are emitted as Markdown pipe tables. Complex layouts (merged cells, multi-line cells) can come out split or shifted, so check tables before feeding them to downstream parsers.
What does it cost?
3 credits per started 5 MB of input with the built-in extractor. Files over 2 MB are processed as an async job.
Try it on your own files
New workspaces start with a 14-day Starter trial (3,000 credits). Create an API key on the API Keys page and run the examples above as they are.