O3O DocBuilder và chuyển đổi
Dựng tài liệu từ kịch bản
POST /v1/build nhận một kịch bản o3oscript và trả về văn bản, bảng tính hoặc trình chiếu ở dạng docx, odt, xlsx, ods, pptx, odp hoặc PDF; trang này mô tả luồng gọi, thân yêu cầu, nhiều tệp ra và đơn vị kịch bản.
Trong trang này
Thay vì soạn mẫu rồi điền, bạn mô tả tài liệu cần có bằng JSON: tiêu đề, đoạn văn, bảng, trang tính, trang chiếu. Mã của bạn tự sinh JSON từ dữ liệu (vòng lặp và điều kiện nằm ở phía bạn), DocBuilder dựng tài liệu bằng LibreOffice rồi lưu ra định dạng yêu cầu. Cú pháp đầy đủ ở trang o3oscript.
/v1/buildDựng tài liệu từ kịch bản o3oscript.
Luồng gọi#
Viết kịch bản
Sinh JSON theo lược đồo3oscript-v1.schema.jsontừ dữ liệu của bạn. Một kịch bản dựng đúng một tài liệu thuộc một loại:text,sheethoặcslides.Kiểm tra ở phía bạn (nên làm)
Kiểm kịch bản bằng một thư viện JSON Schema draft-07 để bắt lỗi sớm mà không tốn lượt gọi.Gửi POST /v1/build
Máy chủ kiểm lại bằng JSON Schema TRƯỚC khi đưa vào hàng đợi (sai:422 script_invalid), rồi đếm đơn vị kịch bản so vớimax_script_unitscủa gói (vượt:422 script_too_large).DocBuilder dựng tài liệu
Một worker rảnh tạo tài liệu trống, dựng nội dung qua UNO theo đúng thứ tự trong kịch bản rồi lưu từng định dạng trongsave. Lỗi khi dựng, ví dụ ảnh không tải được, cho mãscript_errorkèm JSON Pointer tới phần tử gây lỗi.Nhận kết quả
Đồng bộ: thân phản hồi là tệp. Bất đồng bộ: nhận202kèm job, hỏiGET /v1/jobs/{id}rồi tải từng tệp ởGET /v1/files/{id}.
Thân yêu cầu#
Thân là JSON và được nhận ở một trong hai dạng: đối tượng bọc {"script": {...}, "async": false}, hoặc CHÍNH kịch bản (đối tượng có khoá o3oscript ở gốc), khi đó coi như async = false.
Đối tượng bọc
scriptobjectbắt buộcKịch bản o3oscript.asyncbooleantuỳ chọnMặc định:falsetrue: trả ngay202kèm job. Bắt buộc khisavecó nhiều hơn một phần tử.callback_urlstring (URL)tuỳ chọnChỉ bản doanh nghiệp có tính năngcallback. NhậnPOSTkhi job xong hoặc lỗi.
Ví dụ tối thiểu#
Kịch bản dưới dựng một thư mời một trang và lưu thành docx. Ví dụ gửi CHÍNH kịch bản làm thân yêu cầu nên chạy đồng bộ và nhận thẳng tệp.
{
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}curl -sS http://localhost:8080/v1/build \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-H "Content-Type: application/json" \
--data-binary @- \
-o thu-moi.docx -w "HTTP %{http_code}\n" <<'JSON'
{
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}
JSONimport { writeFile } from "node:fs/promises";
const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const payload = {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
};
const res = await fetch(`${BASE_URL}/v1/build`, {
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()}`);
await writeFile("thu-moi.docx", Buffer.from(await res.arrayBuffer()));import requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
payload = {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}
r = requests.post(f"{BASE_URL}/v1/build", headers=HEADERS, json=payload, timeout=90)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
with open("thu-moi.docx", "wb") as fh:
fh.write(r.content)<?php
$payload = json_encode([
'o3oscript' => 1,
'type' => 'text',
'meta' => ['title' => 'Thư mời họp', 'lang' => 'vi-VN'],
'body' => [
['type' => 'heading', 'level' => 1, 'text' => 'Thư mời họp'],
[
'type' => 'paragraph',
'text' => 'Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3.',
],
],
'save' => [['format' => 'docx', 'filename' => 'thu-moi.docx']],
], JSON_UNESCAPED_UNICODE);
$ch = curl_init("http://localhost:8080/v1/build");
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));
}
file_put_contents("thu-moi.docx", $body);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 = """
{
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Thư mời họp",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "Thư mời họp"
},
{
"type": "paragraph",
"text": "Kính mời anh chị dự buổi họp giao ban lúc 9 giờ sáng thứ Hai tại phòng họp tầng 3."
}
],
"save": [
{
"format": "docx",
"filename": "thu-moi.docx"
}
]
}
""";
using var res = await http.PostAsync("http://localhost:8080/v1/build",
new StringContent(json, Encoding.UTF8, "application/json"));
if (!res.IsSuccessStatusCode)
throw new HttpRequestException($"{(int)res.StatusCode}: {await res.Content.ReadAsStringAsync()}");
await File.WriteAllBytesAsync("thu-moi.docx", await res.Content.ReadAsByteArrayAsync());{
"X-O3O-Request-Id": "req_0123456789abcdef",
"X-O3O-Job-Id": "job_5f0c2a9e41b7d3c8a6e1f024",
"X-O3O-File-Id": "file_9b3e7d21c4a8f0e65d1b2c37",
"Content-Disposition": "attachment; filename=\"thu-moi.docx\"",
"X-RateLimit-Limit": "10",
"X-RateLimit-Remaining": "9",
"X-RateLimit-Reset": "1789984860",
"X-RateLimit-Limit-Day": "200",
"X-RateLimit-Remaining-Day": "187"
}Nhiều tệp ra#
Mảng save nhận tối đa 5 phần tử, nhưng số tệp ra mỗi lần dựng bị giới hạn bởi max_outputs_per_build của gói: 1 ở bản cộng đồng, 5 ở bản doanh nghiệp. Chế độ đồng bộ yêu cầu save có đúng một phần tử; muốn nhiều tệp ra thì dùng async = true. Yêu cầu vi phạm hai quy tắc này bị từ chối.
curl -sS http://localhost:8080/v1/build \
-H "Authorization: Bearer O3O_DEMO_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": true
}
]
},
"async": true,
"callback_url": "https://erp.example.com/o3o/callback"
}
JSONimport requests
BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
payload = {
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": True
}
]
},
"async": True,
"callback_url": "https://erp.example.com/o3o/callback"
}
r = requests.post(f"{BASE_URL}/v1/build", headers=HEADERS, json=payload, timeout=90)
if not r.ok:
raise RuntimeError(f"{r.status_code}: {r.text}")
print(r.json())const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const payload = {
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": true
}
]
},
"async": true,
"callback_url": "https://erp.example.com/o3o/callback"
};
const res = await fetch(`${BASE_URL}/v1/build`, {
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());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 = """
{
"script": {
"o3oscript": 1,
"type": "text",
"meta": {
"title": "Hợp đồng dịch vụ",
"lang": "vi-VN"
},
"body": [
{
"type": "heading",
"level": 1,
"text": "HỢP ĐỒNG DỊCH VỤ",
"align": "center"
},
{
"type": "paragraph",
"text": "Hai bên thống nhất ký kết hợp đồng với các điều khoản dưới đây."
}
],
"save": [
{
"format": "docx",
"filename": "hop-dong.docx"
},
{
"format": "pdf",
"filename": "hop-dong.pdf",
"pdfa": true
}
]
},
"async": true,
"callback_url": "https://erp.example.com/o3o/callback"
}
""";
using var res = await http.PostAsync("http://localhost:8080/v1/build",
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());{
"id": "job_5f0c2a9e41b7d3c8a6e1f024",
"kind": "build",
"status": "queued",
"created_at": "2026-09-21T10:00:00Z",
"started_at": null,
"finished_at": null,
"duration_ms": null,
"expires_at": null,
"outputs": [],
"error": null,
"callback": null
}GET /v1/jobs/{id}: mỗi phần tử của outputs là một tệp. Giá trị thời gian chỉ minh hoạ cấu trúc, không phải số đo.{
"id": "job_5f0c2a9e41b7d3c8a6e1f024",
"kind": "build",
"status": "done",
"created_at": "2026-09-21T10:00:00Z",
"started_at": "2026-09-21T10:00:01Z",
"finished_at": "2026-09-21T10:00:03Z",
"duration_ms": 2000,
"expires_at": "2026-09-22T10:00:03Z",
"outputs": [
{
"file_id": "file_9b3e7d21c4a8f0e65d1b2c37",
"url": "http://localhost:8080/v1/files/file_9b3e7d21c4a8f0e65d1b2c37",
"filename": "hop-dong.docx",
"format": "docx",
"content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"size": 9412,
"sha256": "6991bec8dce8cbd4366a0fe015cce0ccbda86fdebb5fff3f0d7302e48869a932",
"pages": 1,
"expires_at": "2026-09-22T10:00:03Z"
},
{
"file_id": "file_2c7a91e0b54d3f86a1e9c0d4",
"url": "http://localhost:8080/v1/files/file_2c7a91e0b54d3f86a1e9c0d4",
"filename": "hop-dong.pdf",
"format": "pdf",
"content_type": "application/pdf",
"size": 30871,
"sha256": "4a8ed01db889effd4c25e561af1b01492509efaa6f2406f87e5bc3dd94c17235",
"pages": 1,
"expires_at": "2026-09-22T10:00:03Z"
}
],
"error": null,
"callback": {
"state": "delivered",
"attempts": 1
}
}Đơn vị kịch bản#
Để một kịch bản quá lớn không chiếm worker quá lâu, mỗi gói giới hạn số đơn vị kịch bản: 500 ở bản cộng đồng, 20.000 ở bản doanh nghiệp. Cách đếm theo loại tài liệu:
type | Cách đếm đơn vị kịch bản |
|---|---|
text | Số phần tử của body + tổng số hàng của mọi bảng (kể cả hàng tiêu đề) + tổng số mục của mọi danh sách (mọi cấp). |
sheet | Tổng số hàng trong mọi data[].values + số phần tử formats + số phần tử merges, cộng trên mọi trang tính. |
slides | Số trang chiếu + tổng số gạch đầu dòng. |
| Kịch bản mẫu | type | Đơn vị |
|---|---|---|
bao-gia.json | text | 28 |
bang-luong.json | sheet | 34 |
gioi-thieu.json | slides | 12 |
Ràng buộc kiểm lúc chạy#
Lược đồ không diễn đạt được các ràng buộc dưới đây; máy chủ kiểm khi dựng và trả script_error kèm JSON Pointer nếu vi phạm.
- Mọi hàng của một bảng văn bản phải có cùng số ô với
headerhoặccolumns. - Danh sách lồng tối đa 3 cấp.
- Tên trang tính không được trùng nhau.
- Ảnh base64 phải giải mã được và không quá 10 MB.
- Ảnh theo
urlchịu quy tắc chống SSRF như mọi URL khác mà DocBuilder tự tải.
Kiểm tra kịch bản ở phía bạn#
Lược đồ là JSON Schema draft-07 với $id https://office.o3o.vn/api/schema/o3oscript-v1.schema.json. Tệp có sẵn trong image DocBuilder ở /app/o3oscript-v1.schema.json; chép ra máy bằng docker cp hoặc docker compose cp o3o-docbuilder:/app/o3oscript-v1.schema.json . trong thư mục chứa tệp compose.
import json
from jsonschema import Draft7Validator # pip install jsonschema
with open("o3oscript-v1.schema.json", encoding="utf-8") as f:
validator = Draft7Validator(json.load(f))
with open("bao-gia.json", encoding="utf-8") as f:
script = json.load(f)
errors = sorted(validator.iter_errors(script), key=lambda e: list(e.absolute_path))
for e in errors:
print("/" + "/".join(str(p) for p in e.absolute_path), e.message)
print("hợp lệ" if not errors else f"{len(errors)} lỗi")import { readFile } from "node:fs/promises";
import Ajv from "ajv"; // npm install ajv
const schema = JSON.parse(await readFile("o3oscript-v1.schema.json", "utf8"));
const script = JSON.parse(await readFile("bao-gia.json", "utf8"));
const validate = new Ajv({ allErrors: true, strict: false, validateFormats: false }).compile(schema);
if (validate(script)) console.log("hợp lệ");
else for (const e of validate.errors) console.log(e.instancePath || "/", e.message);Mã lỗi thường gặp#
| HTTP | Mã | Khi nào |
|---|---|---|
| 400 | bad_request | JSON hỏng, thân không phải đối tượng, hoặc đối tượng bọc có khoá lạ. |
| 401 | unauthorized | Thiếu hoặc sai khoá. detail.reason là missing, invalid hoặc no_keys_configured. |
| 403 | forbidden_feature | Gửi callback_url khi đang ở bản cộng đồng. |
| 413 | file_too_large | Tệp vào hoặc tệp ra vượt max_file_mb. detail.limit_mb. |
| 422 | script_invalid | Kịch bản sai JSON Schema. detail.errors = [{path, message}], path là JSON Pointer. |
| 422 | script_too_large | Số đơn vị kịch bản vượt max_script_units. detail.units, detail.limit. |
| 422 | script_error | Kịch bản hợp lệ nhưng thực thi lỗi. detail.path là JSON Pointer tới phần tử gây lỗi. |
| 429 | rate_limited | Vượt hạn mức. detail.window là minute hoặc day; kèm Retry-After. |
| 503 | queue_full | Hàng đợi đã đầy (max_queued_jobs); kèm Retry-After. |
| 504 | timeout | Chế độ đồng bộ quá sync_timeout_seconds, hoặc job quá job_timeout_seconds. Worker bị dừng và dựng lại. |
body có level 7.{
"error": {
"code": "script_invalid",
"message": "Kịch bản không hợp lệ.",
"detail": {
"errors": [
{
"path": "/body/2/level",
"message": "7 lớn hơn giá trị tối đa 6"
}
]
},
"request_id": "req_0123456789abcdef"
}
}{
"error": {
"code": "script_too_large",
"message": "Kịch bản vượt số đơn vị cho phép của gói.",
"detail": {
"units": 812,
"limit": 500
},
"request_id": "req_0123456789abcdef"
}
}body không tải được.{
"error": {
"code": "script_error",
"message": "Không tải được ảnh của kịch bản.",
"detail": {
"path": "/body/7/src/url"
},
"request_id": "req_0123456789abcdef"
}
}