PDF to EPUB chapter structure from bookmarks
A useful EPUB needs a clear reading order, but a PDF often expresses that order only through its bookmark outline.
Run — free
This capability takes an extracted PDF bookmark list and converts every top-level bookmark into one EPUB chapter entry. It preserves bookmark order and titles, assigns stable XHTML filenames, and carries over destination pages when they are available. Nested bookmarks remain section-level information and do not accidentally become standalone chapters. If the source has no top-level bookmarks, the request fails clearly instead of inventing a structure that the document never supplied.
Prepare the bookmark outline
Start with the bookmark outline reported by a PDF parser or inspection tool, not with visually prominent headings copied from page content. Supply the records in their original reading order. Each record needs a title and a numeric level, where level 1 means a top-level bookmark; a one-based destination page is optional. A level 2 or deeper record may describe a subsection, appendix entry, figure, or other nested destination. Those records are validated because malformed outline data should not pass silently, but they are not promoted to EPUB chapters. Titles are trimmed at their edges while their spelling, punctuation, capitalization, and internal spacing remain unchanged. Page values must be positive integers when supplied. This explicit representation avoids guessing from indentation or typography and makes the result repeatable across systems. It also lets a workflow separate PDF extraction from publication planning: one component reads the PDF, while this capability performs the narrow, deterministic mapping needed for the EPUB package.
Understand the generated chapter list
The output contains a chapter count and an ordered chapters array. Every chapter receives a one-based index, the normalized title of its corresponding top-level bookmark, and a stable filename such as chapter-001.xhtml. Filenames are based on position rather than title, so punctuation, duplicate titles, non-Latin text, or later slug rules cannot create collisions. When a top-level bookmark includes a destination page, the chapter also includes source_page; when no page was supplied, that optional field is omitted instead of set to null. The capability does not extract page text, split a PDF, write XHTML, or build an EPUB navigation document. Its purpose is to establish a clean intermediate structure that downstream code can use to name files, assign extracted content, create spine entries, and construct navigation links. Because nested bookmarks never become chapters, the chapter count always matches the number of level-1 records in the source outline, preserving the editorial hierarchy already present in the PDF.
Handle missing or imperfect outlines
A PDF can display pages perfectly while containing no usable bookmarks. In that case, there is no reliable top-level chapter signal, so the capability returns an invalid-input error rather than inferring chapters from page numbers, font sizes, or text patterns. That behavior is important in automated conversion: a fabricated chapter list may look plausible but attach content to the wrong title or flatten an intentional hierarchy. If the outline exists but every bookmark is nested below level 1, the same error is returned because no top-level chapter boundary can be established. Correct the upstream extraction or add a deliberate outline before retrying. Other malformed records fail with a location-specific message, including empty titles, invalid levels, and non-positive page numbers. The implementation performs one bounded pass over at most 10,000 bookmark records, uses no network service, and has no time-dependent behavior. Identical outline input therefore produces identical chapter indexes and filenames, whether it runs in a browser or through the API at $0.002 per request.
What you can do with it
Plan an EPUB conversion
Turn an extracted PDF outline into the chapter manifest that a conversion pipeline can populate with XHTML content.
Audit document navigation
Compare the number and order of intended EPUB chapters with the PDF's existing top-level bookmarks before publishing.
Create stable chapter filenames
Assign collision-free positional XHTML names even when bookmark titles repeat or contain punctuation and non-Latin characters.
FAQ
What does it cost?
The API price is $0.002 per request, and the same deterministic transformation can run in the browser.
Does this capability read the PDF file itself?
No. It accepts the bookmark outline already extracted from a PDF and converts that outline into a chapter list.
What counts as a chapter?
Each bookmark whose level is exactly 1 becomes one chapter, in the same order as the input outline.
What happens to nested bookmarks?
They are validated but are not emitted as chapters. They can be handled later as sections within their top-level chapter.
What if the PDF has no top-level bookmarks?
The request returns an invalid-input error because the capability does not invent chapter boundaries.
Are destination pages required?
No. When a page is present it becomes source_page; otherwise that optional output field is omitted.
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/to-epub-structure \
-H "Authorization: Bearer $KIT_KEY" \
-H "Content-Type: application/json" \
-d '{"outline":[{"title":"Introduction","level":1,"page":1},{"title":"Background","level":2,"page":3},{"title":"Methods","level":1,"page":12}]}'const res = await fetch("https://api.kit.forhosting.com/pdf/to-epub-structure", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.KIT_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
"outline": [
{
"title": "Introduction",
"level": 1,
"page": 1
},
{
"title": "Background",
"level": 2,
"page": 3
},
{
"title": "Methods",
"level": 1,
"page": 12
}
]
})
});
const { task_id } = await res.json();import os, requests
res = requests.post(
"https://api.kit.forhosting.com/pdf/to-epub-structure",
headers={"Authorization": f"Bearer {os.environ['KIT_KEY']}"},
json={
"outline": [
{
"title": "Introduction",
"level": 1,
"page": 1
},
{
"title": "Background",
"level": 2,
"page": 3
},
{
"title": "Methods",
"level": 1,
"page": 12
}
]
},
)
task_id = res.json()["task_id"]<?php
$res = file_get_contents("https://api.kit.forhosting.com/pdf/to-epub-structure", false, stream_context_create([
"http" => [
"method" => "POST",
"header" => "Authorization: Bearer " . getenv("KIT_KEY") . "\r\nContent-Type: application/json",
"content" => '{"outline":[{"title":"Introduction","level":1,"page":1},{"title":"Background","level":2,"page":3},{"title":"Methods","level":1,"page":12}]}',
],
]));
$task = json_decode($res, true);body := bytes.NewBufferString(`{"outline":[{"title":"Introduction","level":1,"page":1},{"title":"Background","level":2,"page":3},{"title":"Methods","level":1,"page":12}]}`)
req, _ := http.NewRequest("POST", "https://api.kit.forhosting.com/pdf/to-epub-structure", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KIT_KEY"))
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)Example request
{
"outline": [
{
"title": "Introduction",
"level": 1,
"page": 1
},
{
"title": "Background",
"level": 2,
"page": 3
},
{
"title": "Methods",
"level": 1,
"page": 12
}
]
}Example response
{
"task_id": "tsk_a1b2c3d4e5f6a1b2c3d4e5f6",
"type": "pdf.to_epub_structure",
"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_mb | 25 |
max_pages | 200 |
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. |