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.
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.
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_ten | chuc_danh | don_vi |
|---|---|---|
| Nguyễn Văn An | Trưởng phòng | Phòng Kỹ thuật |
| Trần Thị Bình | Chuyên viên | Phòng Kinh doanh |
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;
}
}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.
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