O3O DocBuilder and conversion
Extracting text, metadata and thumbnails
Three synchronous endpoints pull content out of documents: POST /v1/extract/text for plain text per page, POST /v1/extract/meta for metadata, POST /v1/extract/thumbnail for an image of one page.
Extraction is always synchronous, creates no job and works on both plans. All three endpoints accept every source format in the format matrix, sent as multipart (field file) or JSON (field url). Text and thumbnails go through an intermediate PDF and then pdftotext or pdftoppm; metadata is read through UNO without rendering pages.
Extracting text#
/v1/extract/textExtract plain text, page by page.
multipart/form-data body
filefilerequiredSource file.fromstringoptionalSource format when the file name has no clear extension.passwordstringoptionalPassword to open the source file.max_charsinteger 1–5.000.000optionalDefault:1000000Truncate the returned text at this many characters.
application/json body
urlURL, ≤ 2048requiredURL of the source file, subject to the SSRF rules.fromstringoptionalSource format when the file name has no clear extension.passwordstringoptionalPassword to open the source file.max_charsinteger 1–5.000.000optionalDefault:1000000Truncate the returned text at this many characters.
curl -sS http://localhost:8080/v1/extract/text \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@bao-gia.docx"import { readFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const form = new FormData();
form.append("file", new Blob([await readFile("bao-gia.docx")]), "bao-gia.docx");
const res = await fetch(`${BASE_URL}/v1/extract/text`, {
method: "POST",
headers: HEADERS,
body: form,
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
with open("bao-gia.docx", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/extract/text",
headers=HEADERS,
files={"file": ("bao-gia.docx", f)},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
print(r.json())<?php
$ch = curl_init("http://localhost:8080/v1/extract/text");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('bao-gia.docx'),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
print_r(json_decode($body, true));using System.Net.Http.Headers;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(await File.ReadAllBytesAsync("bao-gia.docx")), "file", "bao-gia.docx");
using var res = await http.PostAsync("http://localhost:8080/v1/extract/text", form);
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());{
"text": "BÁO GIÁ BẢN QUYỀN\nSố: BG-2026-0917 · Ngày lập: 21/09/2026\n1. Thông tin khách hàng\nKính gửi: Công ty TNHH Thương mại và Dịch vụ An Phát\n\nPhụ lục: Cách đếm kết nối\nMột người mở ba tài liệu ở chế độ sửa được tính là ba kết nối.",
"pages": [
{
"page": 1,
"text": "BÁO GIÁ BẢN QUYỀN\nSố: BG-2026-0917 · Ngày lập: 21/09/2026\n1. Thông tin khách hàng\nKính gửi: Công ty TNHH Thương mại và Dịch vụ An Phát"
},
{
"page": 2,
"text": "Phụ lục: Cách đếm kết nối\nMột người mở ba tài liệu ở chế độ sửa được tính là ba kết nối."
}
],
"page_count": 2,
"chars": 226,
"truncated": false
}textis the whole text with pages joined by two line breaks, truncated atmax_chars.charsis the length of the FULL text before truncation;truncatedsays whether it was cut.pagesgives the text of each page, handy for page-level search indexing.
Common errors: 400 bad_request when neither file nor url is sent or max_chars is out of range, 415 unsupported_format, 422 corrupt_source, 422 password_required. The full table is at the end of the page.
Metadata#
/v1/extract/metaExtract document metadata and statistics.
multipart/form-data body
filefilerequiredSource file.fromstringoptionalSource format when the file name has no clear extension.passwordstringoptionalPassword to open the source file.
application/json body
urlURL, ≤ 2048requiredURL of the source file, subject to the SSRF rules.fromstringoptionalSource format when the file name has no clear extension.passwordstringoptionalPassword to open the source file.
curl -sS http://localhost:8080/v1/extract/meta \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/files/bang-luong.xlsx"}'const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const payload = {"url": "https://example.com/files/bang-luong.xlsx"};
const res = await fetch(`${BASE_URL}/v1/extract/meta`, {
method: "POST",
headers: { ...HEADERS, "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
payload = {"url": "https://example.com/files/bang-luong.xlsx"}
r = requests.post(f"{BASE_URL}/v1/extract/meta", headers=HEADERS, json=payload, timeout=90)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
print(r.json())<?php
$payload = json_encode(['url' => 'https://example.com/files/bang-luong.xlsx'], JSON_UNESCAPED_UNICODE);
$ch = curl_init("http://localhost:8080/v1/extract/meta");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
print_r(json_decode($body, true));using System.Net.Http.Headers;
using System.Text;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
var json = """
{
"url": "https://example.com/files/bang-luong.xlsx"
}
""";
using var res = await http.PostAsync("http://localhost:8080/v1/extract/meta",
new StringContent(json, Encoding.UTF8, "application/json"));
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
Console.WriteLine(await res.Content.ReadAsStringAsync());sheets lists the sheet names; text statistics are null.{
"family": "sheet",
"format": "xlsx",
"size": 9876,
"title": "Bảng lương tháng 9 năm 2026",
"subject": null,
"author": "Phòng Hành chính Nhân sự",
"description": null,
"keywords": [],
"created": "2026-09-21T10:00:00Z",
"modified": "2026-09-21T10:00:00Z",
"generator": "LibreOffice/7.4.7.2$Linux_X86_64",
"pages": null,
"words": null,
"chars": null,
"paragraphs": null,
"sheets": [
"Bảng lương",
"Theo phòng ban"
],
"slides": null
}Result fields
familytext | sheet | sliderequiredDocument family.formatstringrequiredDetected source format.sizeintegerrequiredSource file size in bytes.title, subject, author, descriptionstring | nulloptionalDocument properties.keywordsarrayoptionalKeywords.created, modifieddate-time | nulloptionalCreation and modification time.generatorstring | nulloptionalSoftware that produced the file.pagesinteger | nulloptionalPage count (textfamily),nullotherwise.words, chars, paragraphsinteger | nulloptionalDocument statistics.sheetsarrayoptionalSheet names (sheetfamily), an empty array otherwise.slidesinteger | nulloptionalSlide count (slidefamily).
Common errors: 401 unauthorized, 413 file_too_large, 415 unsupported_format, 422 corrupt_source, 422 password_required; with a URL source also 422 url_not_allowed and 422 download_failed.
Thumbnails#
/v1/extract/thumbnailA PNG or JPG image of one page, returned directly.
multipart/form-data body
filefilerequiredSource file.fromstringoptionalSource format when the file name has no clear extension.passwordstringoptionalPassword to open the source file.pageinteger ≥ 1optionalDefault:1Page to render.widthinteger 16–2000optionalDefault:320Image width in pixels.formatpng | jpgoptionalDefault:pngImage format.
application/json body
urlURL, ≤ 2048requiredURL of the source file, subject to the SSRF rules.fromstringoptionalSource format when the file name has no clear extension.passwordstringoptionalPassword to open the source file.pageinteger ≥ 1optionalDefault:1Page to render.widthinteger 16–2000optionalDefault:320Image width in pixels.formatpng | jpgoptionalDefault:pngImage format.
curl -sS http://localhost:8080/v1/extract/thumbnail \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-F "file=@gioi-thieu.pptx" \
-F "page=1" \
-F "width=320" \
-F "format=png" \
-D - \
-o trang-bia.png -w "HTTP %{http_code}\n"import { readFile, writeFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const form = new FormData();
form.append("file", new Blob([await readFile("gioi-thieu.pptx")]), "gioi-thieu.pptx");
form.append("page", "1");
form.append("width", "320");
form.append("format", "png");
const res = await fetch(`${BASE_URL}/v1/extract/thumbnail`, {
method: "POST",
headers: HEADERS,
body: form,
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("trang-bia.png", Buffer.from(await res.arrayBuffer()));
console.log("X-O3O-Page-Count:", res.headers.get("X-O3O-Page-Count"));import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
with open("gioi-thieu.pptx", "rb") as f:
r = requests.post(
f"{BASE_URL}/v1/extract/thumbnail",
headers=HEADERS,
files={"file": ("gioi-thieu.pptx", f)},
data={
"page": "1",
"width": "320",
"format": "png",
},
timeout=90,
)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("trang-bia.png", "wb") as fh:
fh.write(r.content)
print("X-O3O-Page-Count:", r.headers.get("X-O3O-Page-Count"))<?php
$headers = [];
$ch = curl_init("http://localhost:8080/v1/extract/thumbnail");
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => ["Authorization: Bearer O3O_DEMO_KEY"],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile('gioi-thieu.pptx'),
'page' => '1',
'width' => '320',
'format' => 'png',
],
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$parts = explode(":", $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 90,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($body === false || $status >= 400) {
throw new RuntimeException("HTTP $status: " . ($body === false ? curl_error($ch) : $body));
}
file_put_contents("trang-bia.png", $body);
echo "X-O3O-Page-Count: " . ($headers["x-o3o-page-count"] ?? "") . "\n";using System.Net.Http.Headers;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(90) };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "O3O_DEMO_KEY");
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(await File.ReadAllBytesAsync("gioi-thieu.pptx")), "file", "gioi-thieu.pptx");
form.Add(new StringContent("1"), "page");
form.Add(new StringContent("320"), "width");
form.Add(new StringContent("png"), "format");
using var res = await http.PostAsync("http://localhost:8080/v1/extract/thumbnail", form);
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
await File.WriteAllBytesAsync("trang-bia.png", await res.Content.ReadAsByteArrayAsync());
Console.WriteLine("X-O3O-Page-Count: " + string.Join(",", res.Headers.GetValues("X-O3O-Page-Count")));X-O3O-Page-Count header gives the total page count, handy for page navigation when showing thumbnails.{
"X-O3O-Request-Id": "req_0123456789abcdef",
"X-O3O-Page-Count": "3",
"X-RateLimit-Limit": "10",
"X-RateLimit-Remaining": "9",
"X-RateLimit-Reset": "1789984860"
}Common errors#
| HTTP | Code | When |
|---|---|---|
| 400 | bad_request | Neither file nor url, or page beyond the page count. |
| 401 | unauthorized | Missing or wrong key. detail.reason is missing, invalid or no_keys_configured. |
| 413 | file_too_large | Input or output file exceeds max_file_mb. detail.limit_mb. |
| 415 | unsupported_format | Source format not recognised, or the source/target pair is not supported. |
| 422 | corrupt_source | LibreOffice cannot open the source file. |
| 422 | password_required | The file is password protected and no password was sent, or the password is wrong. |
| 422 | url_not_allowed | The URL violates the SSRF protection rules. |
| 422 | download_failed | The source URL could not be downloaded. |
| 429 | rate_limited | Rate limit exceeded. detail.window is minute or day; comes with Retry-After. |
| 503 | pool_unavailable | No worker is alive. |
| 504 | timeout | Synchronous mode exceeded sync_timeout_seconds, or the job exceeded job_timeout_seconds. The worker is killed and restarted. |
{
"error": {
"code": "bad_request",
"message": "Tham số không hợp lệ.",
"detail": {
"errors": [
{
"path": "/page",
"message": "Trang 9 vượt số trang của tài liệu (3)."
}
]
},
"request_id": "req_0123456789abcdef"
}
}Coming later#
Document structure extraction (/v1/extract/structure), embedded media extraction (/v1/extract/media) and comparing two documents (/v1/compare).