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

O3O DocBuilder và chuyển đổi

Công thức thực tế

Bốn công thức chạy được: báo giá ra PDF từ dữ liệu đơn hàng, bảng lương ra xlsx có công thức, trộn thư hàng loạt từ tệp CSV, và ảnh thu nhỏ cho cả một thư mục tài liệu.

Trong trang này

Mỗi công thức là một chương trình hoàn chỉnh, chép vào tệp rồi chạy. Chỉ cần đổi địa chỉ máy chủ mẫu http://localhost:8080 và khoá O3O_DEMO_KEY cho khớp máy chủ của bạn.

1. Báo giá ra PDF từ dữ liệu đơn hàng#

Chương trình dựng kịch bản văn bản từ dữ liệu đơn hàng (vòng lặp và phép tính nằm trong mã của bạn) rồi gọi POST /v1/build với savepdf: một lần gọi ra thẳng PDF, không cần chuyển đổi thêm. Dùng được ở cả hai gói; mỗi dòng hàng tính một đơn vị kịch bản.

Báo giá ra PDF
import requests

BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}

order = {
    "so": "BG-2026-0917",
    "ngay": "21/09/2026",
    "khach": "Công ty TNHH Thương mại và Dịch vụ An Phát",
    "dong": [
        {"ten": "Thiết kế giao diện", "so_luong": 1, "don_gia": 8000000},
        {"ten": "Lập trình và kiểm thử", "so_luong": 3, "don_gia": 8000000},
        {"ten": "Đào tạo sử dụng", "so_luong": 2, "don_gia": 1500000},
    ],
}


def vnd(amount: int) -> str:
    return f"{amount:,}".replace(",", ".")


rows, total = [], 0
for i, line in enumerate(order["dong"], start=1):
    amount = line["so_luong"] * line["don_gia"]
    total += amount
    rows.append([i, line["ten"], line["so_luong"], vnd(line["don_gia"]), vnd(amount)])
rows.append([{"text": "Tổng cộng", "bold": True, "align": "right"}, None, None, None, {"text": vnd(total), "bold": True}])

filename = f"bao-gia-{order['so']}.pdf"
script = {
    "o3oscript": 1,
    "type": "text",
    "meta": {"title": f"Báo giá {order['so']}", "lang": "vi-VN"},
    "style": {"font": "Liberation Sans", "size": 11},
    "footer": {"content": ["Trang ", {"field": "page_number"}, " / ", {"field": "page_count"}], "size": 9},
    "body": [
        {"type": "heading", "level": 1, "text": "BÁO GIÁ", "align": "center"},
        {"type": "paragraph", "align": "center", "text": f"Số: {order['so']} · Ngày: {order['ngay']}"},
        {"type": "paragraph", "runs": [{"text": "Kính gửi: ", "bold": True}, {"text": order["khach"]}]},
        {
            "type": "table",
            "columns": [{"width": 1, "align": "center"}, {"width": 6}, {"width": 2, "align": "right"},
                        {"width": 3, "align": "right"}, {"width": 3, "align": "right"}],
            "header": ["STT", "Hạng mục", "Số lượng", "Đơn giá (đ)", "Thành tiền (đ)"],
            "rows": rows,
            "border": {"width": 0.5, "color": "#7F7F7F"},
            "header_fill": "#1D55B8",
            "header_color": "#FFFFFF",
        },
        {"type": "paragraph", "spacing_before": 6, "text": "Báo giá có hiệu lực 30 ngày kể từ ngày lập."},
    ],
    "save": [{"format": "pdf", "filename": filename}],
}

r = requests.post(f"{BASE_URL}/v1/build", headers=HEADERS, json=script, timeout=90)
if not r.ok:
    raise RuntimeError(f"{r.status_code}: {r.text}")
with open(filename, "wb") as fh:
    fh.write(r.content)
print("Đã tạo", filename)
import { writeFile } from "node:fs/promises";

const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY", "Content-Type": "application/json" };

const order = {
  so: "BG-2026-0917",
  ngay: "21/09/2026",
  khach: "Công ty TNHH Thương mại và Dịch vụ An Phát",
  dong: [
    { ten: "Thiết kế giao diện", so_luong: 1, don_gia: 8000000 },
    { ten: "Lập trình và kiểm thử", so_luong: 3, don_gia: 8000000 },
    { ten: "Đào tạo sử dụng", so_luong: 2, don_gia: 1500000 },
  ],
};

const vnd = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ".");

let total = 0;
const rows = order.dong.map((line, i) => {
  const amount = line.so_luong * line.don_gia;
  total += amount;
  return [i + 1, line.ten, line.so_luong, vnd(line.don_gia), vnd(amount)];
});
rows.push([{ text: "Tổng cộng", bold: true, align: "right" }, null, null, null, { text: vnd(total), bold: true }]);

const filename = `bao-gia-${order.so}.pdf`;
const script = {
  o3oscript: 1,
  type: "text",
  meta: { title: `Báo giá ${order.so}`, lang: "vi-VN" },
  style: { font: "Liberation Sans", size: 11 },
  footer: { content: ["Trang ", { field: "page_number" }, " / ", { field: "page_count" }], size: 9 },
  body: [
    { type: "heading", level: 1, text: "BÁO GIÁ", align: "center" },
    { type: "paragraph", align: "center", text: `Số: ${order.so} · Ngày: ${order.ngay}` },
    { type: "paragraph", runs: [{ text: "Kính gửi: ", bold: true }, { text: order.khach }] },
    {
      type: "table",
      columns: [{ width: 1, align: "center" }, { width: 6 }, { width: 2, align: "right" },
                { width: 3, align: "right" }, { width: 3, align: "right" }],
      header: ["STT", "Hạng mục", "Số lượng", "Đơn giá (đ)", "Thành tiền (đ)"],
      rows,
      border: { width: 0.5, color: "#7F7F7F" },
      header_fill: "#1D55B8",
      header_color: "#FFFFFF",
    },
    { type: "paragraph", spacing_before: 6, text: "Báo giá có hiệu lực 30 ngày kể từ ngày lập." },
  ],
  save: [{ format: "pdf", filename }],
};

const res = await fetch(`${BASE_URL}/v1/build`, { method: "POST", headers: HEADERS, body: JSON.stringify(script) });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile(filename, Buffer.from(await res.arrayBuffer()));
console.log("Đã tạo", filename);
<?php
$order = [
    'so' => 'BG-2026-0917',
    'ngay' => '21/09/2026',
    'khach' => 'Công ty TNHH Thương mại và Dịch vụ An Phát',
    'dong' => [
        ['ten' => 'Thiết kế giao diện', 'so_luong' => 1, 'don_gia' => 8000000],
        ['ten' => 'Lập trình và kiểm thử', 'so_luong' => 3, 'don_gia' => 8000000],
        ['ten' => 'Đào tạo sử dụng', 'so_luong' => 2, 'don_gia' => 1500000],
    ],
];

$vnd = fn (int $n): string => number_format($n, 0, ',', '.');
$rows = [];
$total = 0;
foreach ($order['dong'] as $i => $line) {
    $amount = $line['so_luong'] * $line['don_gia'];
    $total += $amount;
    $rows[] = [$i + 1, $line['ten'], $line['so_luong'], $vnd($line['don_gia']), $vnd($amount)];
}
$rows[] = [['text' => 'Tổng cộng', 'bold' => true, 'align' => 'right'], null, null, null, ['text' => $vnd($total), 'bold' => true]];

$filename = "bao-gia-{$order['so']}.pdf";
$script = [
    'o3oscript' => 1,
    'type' => 'text',
    'meta' => ['title' => "Báo giá {$order['so']}", 'lang' => 'vi-VN'],
    'style' => ['font' => 'Liberation Sans', 'size' => 11],
    'body' => [
        ['type' => 'heading', 'level' => 1, 'text' => 'BÁO GIÁ', 'align' => 'center'],
        ['type' => 'paragraph', 'align' => 'center', 'text' => "Số: {$order['so']} · Ngày: {$order['ngay']}"],
        ['type' => 'paragraph', 'runs' => [['text' => 'Kính gửi: ', 'bold' => true], ['text' => $order['khach']]]],
        [
            'type' => 'table',
            'header' => ['STT', 'Hạng mục', 'Số lượng', 'Đơn giá (đ)', 'Thành tiền (đ)'],
            'rows' => $rows,
            'border' => ['width' => 0.5, 'color' => '#7F7F7F'],
        ],
    ],
    'save' => [['format' => 'pdf', 'filename' => $filename]],
];

$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 => json_encode($script, JSON_UNESCAPED_UNICODE),
    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($filename, $body);

2. Bảng lương ra xlsx có công thức#

Mỗi nhân sự thành một hàng; bảo hiểm và thực lĩnh là công thức nên bảng tính vẫn tự tính lại khi kế toán sửa ngày công. Hàng tổng dùng SUM, định dạng số #,##0, hàng tiêu đề được cố định.

Bảng lương ra xlsx
import requests

BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
WORKDAYS = 22

staff = [
    ("Nguyễn Văn An", "Kỹ thuật", 18000000, 22),
    ("Trần Thị Bình", "Kinh doanh", 15000000, 21),
    ("Lê Hoàng Cường", "Kỹ thuật", 22000000, 22),
]

values = [["STT", "Họ và tên", "Phòng ban", "Lương cơ bản", "Ngày công", "Bảo hiểm (10,5%)", "Thực lĩnh"]]
for i, (name, dept, salary, days) in enumerate(staff, start=1):
    row = i + 1  # hàng trên trang tính; hàng 1 là tiêu đề
    values.append([i, name, dept, salary, days,
                   {"f": f"=ROUND(D{row}*0.105,0)"},
                   {"f": f"=ROUND(D{row}/{WORKDAYS}*E{row},0)-F{row}"}])
last = len(staff) + 1
total_row = last + 1
values.append(["Tổng cộng", None, None, {"f": f"=SUM(D2:D{last})"}, None,
               {"f": f"=SUM(F2:F{last})"}, {"f": f"=SUM(G2:G{last})"}])

script = {
    "o3oscript": 1,
    "type": "sheet",
    "meta": {"title": "Bảng lương tháng 9 năm 2026", "lang": "vi-VN"},
    "sheets": [{
        "name": "Bảng lương",
        "columns": [{"col": c, "width": w} for c, w in zip("ABCDEFG", (12, 48, 32, 30, 22, 30, 32))],
        "data": [{"at": "A1", "values": values}],
        "formats": [
            {"range": "A1:G1", "bold": True, "fill": "#167A41", "color": "#FFFFFF", "align": "center", "wrap": True},
            {"range": f"D2:D{total_row}", "number_format": "#,##0"},
            {"range": f"F2:G{total_row}", "number_format": "#,##0"},
            {"range": f"A{total_row}:G{total_row}", "bold": True},
            {"range": f"A1:G{total_row}", "border": {"width": 0.5, "color": "#7F7F7F"}},
        ],
        "merges": [f"A{total_row}:C{total_row}"],
        "freeze": {"rows": 1, "cols": 2},
    }],
    "save": [{"format": "xlsx", "filename": "bang-luong-2026-09.xlsx"}],
}

r = requests.post(f"{BASE_URL}/v1/build", headers=HEADERS, json=script, timeout=90)
if not r.ok:
    raise RuntimeError(f"{r.status_code}: {r.text}")
with open("bang-luong-2026-09.xlsx", "wb") as fh:
    fh.write(r.content)
import { writeFile } from "node:fs/promises";

const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY", "Content-Type": "application/json" };
const WORKDAYS = 22;

const staff = [
  ["Nguyễn Văn An", "Kỹ thuật", 18000000, 22],
  ["Trần Thị Bình", "Kinh doanh", 15000000, 21],
  ["Lê Hoàng Cường", "Kỹ thuật", 22000000, 22],
];

const values = [["STT", "Họ và tên", "Phòng ban", "Lương cơ bản", "Ngày công", "Bảo hiểm (10,5%)", "Thực lĩnh"]];
staff.forEach(([name, dept, salary, days], i) => {
  const row = i + 2; // hàng trên trang tính; hàng 1 là tiêu đề
  values.push([i + 1, name, dept, salary, days,
    { f: `=ROUND(D${row}*0.105,0)` },
    { f: `=ROUND(D${row}/${WORKDAYS}*E${row},0)-F${row}` }]);
});
const last = staff.length + 1;
const totalRow = last + 1;
values.push(["Tổng cộng", null, null, { f: `=SUM(D2:D${last})` }, null, { f: `=SUM(F2:F${last})` }, { f: `=SUM(G2:G${last})` }]);

const widths = { A: 12, B: 48, C: 32, D: 30, E: 22, F: 30, G: 32 };
const script = {
  o3oscript: 1,
  type: "sheet",
  meta: { title: "Bảng lương tháng 9 năm 2026", lang: "vi-VN" },
  sheets: [{
    name: "Bảng lương",
    columns: Object.entries(widths).map(([col, width]) => ({ col, width })),
    data: [{ at: "A1", values }],
    formats: [
      { range: "A1:G1", bold: true, fill: "#167A41", color: "#FFFFFF", align: "center", wrap: true },
      { range: `D2:D${totalRow}`, number_format: "#,##0" },
      { range: `F2:G${totalRow}`, number_format: "#,##0" },
      { range: `A${totalRow}:G${totalRow}`, bold: true },
      { range: `A1:G${totalRow}`, border: { width: 0.5, color: "#7F7F7F" } },
    ],
    merges: [`A${totalRow}:C${totalRow}`],
    freeze: { rows: 1, cols: 2 },
  }],
  save: [{ format: "xlsx", filename: "bang-luong-2026-09.xlsx" }],
};

const res = await fetch(`${BASE_URL}/v1/build`, { method: "POST", headers: HEADERS, body: JSON.stringify(script) });
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("bang-luong-2026-09.xlsx", Buffer.from(await res.arrayBuffer()));

3. Trộn thư hàng loạt từ CSV#

Bộ phận hành chính soạn thu-moi-mau.docx có các thẻ {{chuc_danh}}, {{ho_ten}}, {{don_vi}}; dữ liệu nằm trong danh-sach.csv mã UTF-8, dòng đầu là tên trường. Chương trình gọi POST /v1/template/render cho từng dòng và lưu mỗi thư thành một PDF.

ho_tenchuc_danhdon_vi
Nguyễn Văn AnTrưởng phòngPhòng Kỹ thuật
Trần Thị BìnhChuyên viênPhòng Kinh doanh
Trộn thư, mỗi người một PDF
import csv
import json
import os
import time

import requests

BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}


def render(template_path: str, data: dict, out_path: str, attempts: int = 5) -> None:
    for _ in range(attempts):
        with open(template_path, "rb") as f:
            r = requests.post(
                f"{BASE_URL}/v1/template/render",
                headers=HEADERS,
                files={"template": (os.path.basename(template_path), f)},
                data={"data": json.dumps(data, ensure_ascii=False), "to": "pdf",
                      "options": json.dumps({"missing": "error"})},
                timeout=90,
            )
        if r.status_code in (429, 503):
            time.sleep(int(r.headers.get("Retry-After", "5")))
            continue
        if not r.ok:
            raise RuntimeError(f"{r.status_code}: {r.text}")
        with open(out_path, "wb") as fh:
            fh.write(r.content)
        return
    raise RuntimeError("Vẫn bị giới hạn sau nhiều lần thử")


os.makedirs("thu-moi", exist_ok=True)
with open("danh-sach.csv", encoding="utf-8-sig", newline="") as f:
    for i, row in enumerate(csv.DictReader(f), start=1):
        render("thu-moi-mau.docx", row, os.path.join("thu-moi", f"{i:04d}.pdf"))
        print("Đã tạo", i, row["ho_ten"])
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";

const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };

// CSV đơn giản: dòng đầu là tên trường, giá trị không chứa dấu phẩy.
const [head, ...lines] = (await readFile("danh-sach.csv", "utf8")).replace(/^/, "").trim().split(/\r?\n/);
const fields = head.split(",");
const template = new Blob([await readFile("thu-moi-mau.docx")]);
await mkdir("thu-moi", { recursive: true });

for (const [index, line] of lines.entries()) {
  const data = Object.fromEntries(line.split(",").map((value, i) => [fields[i], value]));
  for (let attempt = 0; ; attempt++) {
    const form = new FormData();
    form.append("template", template, "thu-moi-mau.docx");
    form.append("data", JSON.stringify(data));
    form.append("to", "pdf");
    form.append("options", JSON.stringify({ missing: "error" }));
    const res = await fetch(`${BASE_URL}/v1/template/render`, { method: "POST", headers: HEADERS, body: form });
    if ((res.status === 429 || res.status === 503) && attempt < 4) {
      await sleep(1000 * Number(res.headers.get("Retry-After") ?? 5));
      continue;
    }
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
    await writeFile(`thu-moi/${String(index + 1).padStart(4, "0")}.pdf`, Buffer.from(await res.arrayBuffer()));
    console.log("Đã tạo", index + 1, data.ho_ten);
    break;
  }
}
Sắp có

Điền mẫu hàng loạt trong một lần gọi (/v1/template/render-batch).

4. Ảnh thu nhỏ cho một thư mục tài liệu#

Chương trình duyệt thư mục tai-lieu, gọi POST /v1/extract/thumbnail cho từng tệp có định dạng được hỗ trợ, lưu ảnh PNG rộng 320 điểm ảnh vào anh-thu-nho và ghi số trang lấy từ header X-O3O-Page-Count vào index.json. Tệp lỗi được bỏ qua, không làm dừng cả lô.

Ảnh thu nhỏ cho cả thư mục
import json
import pathlib
import time

import requests

BASE_URL = "http://localhost:8080"
HEADERS = {"Authorization": "Bearer O3O_DEMO_KEY"}
SUPPORTED = {".docx", ".doc", ".odt", ".rtf", ".txt", ".html", ".xlsx", ".xls", ".ods", ".csv", ".pptx", ".ppt", ".odp"}

src = pathlib.Path("tai-lieu")
dst = pathlib.Path("anh-thu-nho")
dst.mkdir(exist_ok=True)
index = {}

for path in sorted(p for p in src.iterdir() if p.suffix.lower() in SUPPORTED):
    while True:
        with path.open("rb") as f:
            r = requests.post(f"{BASE_URL}/v1/extract/thumbnail", headers=HEADERS, files={"file": (path.name, f)},
                              data={"page": "1", "width": "320", "format": "png"}, timeout=90)
        if r.status_code not in (429, 503):
            break
        time.sleep(int(r.headers.get("Retry-After", "5")))
    if not r.ok:
        print("Bỏ qua", path.name, r.status_code)
        continue
    (dst / f"{path.stem}.png").write_bytes(r.content)
    index[path.name] = {"thumbnail": f"{path.stem}.png", "pages": int(r.headers["X-O3O-Page-Count"])}

(dst / "index.json").write_text(json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
print(len(index), "ảnh thu nhỏ")
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
import { extname, basename } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";

const BASE_URL = "http://localhost:8080";
const HEADERS = { Authorization: "Bearer O3O_DEMO_KEY" };
const SUPPORTED = new Set([".docx", ".doc", ".odt", ".rtf", ".txt", ".html", ".xlsx", ".xls", ".ods", ".csv", ".pptx", ".ppt", ".odp"]);

await mkdir("anh-thu-nho", { recursive: true });
const index = {};

for (const name of (await readdir("tai-lieu")).sort()) {
  if (!SUPPORTED.has(extname(name).toLowerCase())) continue;
  const file = new Blob([await readFile(`tai-lieu/${name}`)]);
  let res;
  for (;;) {
    const form = new FormData();
    form.append("file", file, name);
    form.append("width", "320");
    res = await fetch(`${BASE_URL}/v1/extract/thumbnail`, { method: "POST", headers: HEADERS, body: form });
    if (res.status !== 429 && res.status !== 503) break;
    await sleep(1000 * Number(res.headers.get("Retry-After") ?? 5));
  }
  if (!res.ok) {
    console.log("Bỏ qua", name, res.status);
    continue;
  }
  const stem = basename(name, extname(name));
  await writeFile(`anh-thu-nho/${stem}.png`, Buffer.from(await res.arrayBuffer()));
  index[name] = { thumbnail: `${stem}.png`, pages: Number(res.headers.get("X-O3O-Page-Count")) };
}

await writeFile("anh-thu-nho/index.json", JSON.stringify(index, null, 2));
console.log(Object.keys(index).length, "ảnh thu nhỏ");
mkdir -p anh-thu-nho
for f in tai-lieu/*; do
  name=$(basename "$f")
  code=$(curl -sS http://localhost:8080/v1/extract/thumbnail \
    -H "Authorization: Bearer O3O_DEMO_KEY" \
    -F "file=@$f" -F "width=320" \
    -D anh-thu-nho/.headers \
    -o "anh-thu-nho/${name%.*}.png" -w '%{http_code}')
  if [ "$code" = 200 ]; then
    pages=$(grep -i '^x-o3o-page-count:' anh-thu-nho/.headers | tr -dc '0-9')
    echo "$name: $pages trang"
  else
    echo "$name: bỏ qua (HTTP $code)"
    rm -f "anh-thu-nho/${name%.*}.png"
  fi
done
rm -f anh-thu-nho/.headers