Skip to content

O3O DocBuilder and conversion

Practical recipes

Four working recipes: a quotation PDF from order data, a payroll xlsx with formulas, a mail merge from a CSV file, and thumbnails for a whole folder of documents.

On this page

Each recipe is a complete program: paste it into a file and run it. Only change the sample server address http://localhost:8080 and the O3O_DEMO_KEY key to match your server.

1. A quotation PDF from order data#

The program builds a text script from order data (loops and arithmetic stay in your code) and calls POST /v1/build with a pdf save entry: one call produces the PDF directly, with no extra conversion. Works on both plans; each order line costs one script unit.

Quotation to 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("Created", 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("Created", 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. A payroll xlsx with formulas#

Each employee becomes a row; insurance and net pay are formulas, so the workbook recalculates when accountants edit the working days. The total row uses SUM, numbers use #,##0, and the header row is frozen.

Payroll to 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  # row on the sheet; row 1 is the header
    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; // row on the sheet; row 1 is the header
  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. Mail merge from a CSV file#

Office staff write thu-moi-mau.docx with the tags {{chuc_danh}}, {{ho_ten}} and {{don_vi}}; the data sits in danh-sach.csv, UTF-8, with field names on the first line. The program calls POST /v1/template/render for each line and saves every letter as a 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
Mail merge, one PDF per person
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("Still limited after several attempts")


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("Created", 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" };

// Simple CSV: the first line holds field names, values contain no commas.
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("Created", index + 1, data.ho_ten);
    break;
  }
}
Coming soon

Batch template filling in one call (/v1/template/render-batch).

4. Thumbnails for a folder of documents#

The program walks the tai-lieu folder, calls POST /v1/extract/thumbnail for each file in a supported format, saves a 320-pixel-wide PNG into anh-thu-nho and records the page count from the X-O3O-Page-Count header in index.json. Failed files are skipped without stopping the batch.

Thumbnails for a whole folder
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("Skipped", 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), "thumbnails")
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("Skipped", 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, "thumbnails");
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 pages"
  else
    echo "$name: skipped (HTTP $code)"
    rm -f "anh-thu-nho/${name%.*}.png"
  fi
done
rm -f anh-thu-nho/.headers