Bỏ qua, tới nội dung
Tài liệu APIv1

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.

POST/v1/build

Dựng tài liệu từ kịch bản o3oscript.

Xác thực: BearerCộng đồngDoanh nghiệp

Luồng gọi#

  1. Viết kịch bản

    Sinh JSON theo lược đồ o3oscript-v1.schema.json từ 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, sheet hoặc slides.
  2. 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.
  3. 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ới max_script_units của gói (vượt: 422 script_too_large).
  4. 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 trong save. Lỗi khi dựng, ví dụ ảnh không tải được, cho mã script_error kèm JSON Pointer tới phần tử gây lỗi.
  5. Nhận kết quả

    Đồng bộ: thân phản hồi là tệp. Bất đồng bộ: nhận 202 kèm job, hỏi GET /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ộc
    Kịch bản o3oscript.
  • asyncbooleantuỳ chọnMặc định: false
    true: trả ngay 202 kèm job. Bắt buộc khi save có nhiều hơn một phần tử.
  • callback_urlstring (URL)tuỳ chọn
    Chỉ bản doanh nghiệp có tính năng callback. Nhận POST khi 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.

JSONKịch bản
{
  "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"
    }
  ]
}
Gửi kịch bản, nhận 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"
    }
  ]
}
JSON
import { 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());
200Chế độ đồng bộ: thân phản hồi là tệp nhị phân. Khối dưới đây liệt kê các header đi kèm (bản cộng đồng, nên có cả header theo ngày).
{
  "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.

Hợp đồng ra docx và PDF/A, có callback (bản doanh nghiệp)
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"
}
JSON
import 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());
202Job đã vào hàng đợi.
{
  "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
}
200Job khi đã xong, đọc bằng 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:

typeCách đếm đơn vị kịch bản
textSố 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).
sheetTổ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.
slidesSố trang chiếu + tổng số gạch đầu dòng.
Kịch bản mẫutypeĐơn vị
bao-gia.jsontext28
bang-luong.jsonsheet34
gioi-thieu.jsonslides12

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 header hoặc columns.
  • 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 url chị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.

Kiểm kịch bản bằng JSON Schema
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#

HTTPKhi nào
400bad_requestJSON hỏng, thân không phải đối tượng, hoặc đối tượng bọc có khoá lạ.
401unauthorizedThiếu hoặc sai khoá. detail.reasonmissing, invalid hoặc no_keys_configured.
403forbidden_featureGửi callback_url khi đang ở bản cộng đồng.
413file_too_largeTệp vào hoặc tệp ra vượt max_file_mb. detail.limit_mb.
422script_invalidKịch bản sai JSON Schema. detail.errors = [{path, message}], path là JSON Pointer.
422script_too_largeSố đơn vị kịch bản vượt max_script_units. detail.units, detail.limit.
422script_errorKị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.
429rate_limitedVượt hạn mức. detail.windowminute hoặc day; kèm Retry-After.
503queue_fullHàng đợi đã đầy (max_queued_jobs); kèm Retry-After.
504timeoutChế độ đồng bộ quá sync_timeout_seconds, hoặc job quá job_timeout_seconds. Worker bị dừng và dựng lại.
422Kịch bản sai lược đồ: phần tử thứ ba của bodylevel 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"
  }
}
422Kịch bản vượt số đơn vị của bản cộng đồng.
{
  "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"
  }
}
422Kịch bản hợp lệ nhưng ảnh ở phần tử thứ tám của 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"
  }
}

Ví dụ đầy đủ#