PDF API の TypeScript SDK:最初の呼び出しまで 5 分、そして利用者に残される部分
TypeScript から @pdf123/sdk を使い、ファイルの結合、透かしの追加、ファイル情報の取得、複数のツールを一つのリクエストにまとめる処理を行う。ツール名やパラメータの綴り間違いはアップロード前に検出され、サーバーの拒否には reason と hint が付くが、再試行、キャンセル、バッチ内の部分的な失敗の扱いは利用者の側に残る。

Node から PDF API を呼ぶとき、ソフトウェア開発キット(SDK)の @pdf123/sdk は、ツール名やパラメータの綴り間違いをファイルのアップロード前に検出する。再試行も、代わりのキャンセルも、結果のストリーミングもしない。new Pdf123Client() は既定で匿名のまま https://pdf123.xyz を指す。ファイルはそこへアップロードされ、そこで処理される。ローカルのエンジンは無いからだ。
フォルダーに PDF が二つあれば結合できる
Node 20.3 以降、または Bun が必要だ。パッケージは ES モジュールなので、コードを .mjs ファイルに置くか、先に npm pkg set type=module を実行する。a.pdf と b.pdf をカレントディレクトリに置く。
npm install @pdf123/sdk
// merge.mjs
import { Pdf123Client } from "@pdf123/sdk";
import { readFileInput, saveResult } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const merged = await client.run("merge", {
input: [await readFileInput("a.pdf"), await readFileInput("b.pdf")],
});
console.log(await saveResult(merged, { output: "merged.pdf" }));
node merge.mjs を実行すると、ターミナルには渡した出力パス merged.pdf が表示され、ファイルはカレントディレクトリにできている。readFileInput と saveResult は @pdf123/sdk/node にある。ルートのエントリーは fetch だけに依存し、入力を素の { name, data } で受け取るので、ファイルシステムの無い環境でも同じクライアントを使える。結果は { data, contentType, filename, json } で、data は丸ごとの Uint8Array だ。Buffer.from(result.data) で Buffer になる。
PDFの結合はツールの一つにすぎない。
ファイルは data に、レポートは json に返る
どのツールも client.run(toolName, { input, params }) で呼ぶ。以下のコードは前の節の merge.mjs の続きで、client と saveResult はすでにスコープにある。結果をどこから読むかは、getTool の returns フィールドで決まる。値は "file" か "json" で、前者なら data、後者なら json を使う。
import { getTool } from "@pdf123/sdk";
import { readFileInput, saveResult } from "@pdf123/sdk/node";
// client は前の節の merge.mjs で作ったもの
const marked = await client.run("watermark", {
input: await readFileInput("a.pdf"),
params: { watermarkText: "DRAFT", fontSize: 40 },
});
console.log(await saveResult(marked, { output: "marked.pdf" }));
const info = await client.run("get-info", { input: await readFileInput("a.pdf") });
console.log(info.json.FileSize, info.json.Encrypted);
console.log(getTool("get-info")?.returns); // "json"
最初のブロックは透かしを入れたファイルを marked.pdf に書き出し、二つ目では info.json がレポートになる。フィールド名、既定値、許される値を覚える必要はない。getTool("watermark")?.fields がそのカタログで、コマンドラインの pdfx describe watermark も同じものを読む。TOOLS には 95 個すべてのツールが入っている。完全なリファレンスは開発者ページの SDK ガイドにある。
結合してから透かしを入れ、さらに圧縮するには、pipeline を使うか run を三回続けて呼ぶ。どちらにするかは、途中のファイルが要るかどうかで決まる。これも上の client の続きだ。pipeline は三つのステップを一つのリクエストにまとめ、途中の結果はサーバーに留まり、呼び出し側が受け取るのは最後のステップだけで、下のコードはそれを out.pdf に書き出す。各ステップのファイルが欲しければ、別々に呼ぶ。
const result = await client.pipeline(
[{ tool: "merge" }, { tool: "watermark", params: { watermarkText: "DRAFT" } }, { tool: "compress" }],
[await readFileInput("a.pdf"), await readFileInput("b.pdf")],
);
console.log(await saveResult(result, { output: "out.pdf" }));
パイプラインのステップは最大 8 個だ。9 個目のステップは HTTP 400: at most 8 pipeline steps allowed になる。
匿名で、キーを付けて、または自分のサーバーに向けて
PDFX_API_BASE を設定したのに https://pdf123.xyz に接続してしまうなら、それは new Pdf123Client() が環境変数を読まないからだ。見るのはコンストラクターのオプションだけである。
匿名で呼ぶなら、そのコンストラクターをそのまま使う。キーを付けるなら new Pdf123Client({ apiKey: process.env.PDFX_API_KEY }) と書く。リクエストヘッダーは X-API-KEY だ。
自分のサーバーに向けるには、@pdf123/sdk/node の clientFromEnv() を使う。PDFX_API_BASE と PDFX_API_KEY を読む。new Pdf123Client({ baseUrl: "http://localhost:8080" }) と直接書いてもよい。アドレスに到達できなければ呼び出しは失敗し、オフラインモードは無い。ファイルを自分のネットワーク内に置くと何が得られ、何がコストになるかはセルフホストが実際にもたらすもの(と、そのコスト)で扱っている。
間違ったパラメータはアップロード前に捕まる
これが無ければファイルが先にアップロードされ、フィールド名の綴り間違いはサーバーからの 400 として初めて表に出る。各ツールのパラメータの型はカタログから生成されるので、よくある三つのミスは tsc の段階で失敗する。
| 書いたもの | コンパイラのエラー(抜粋) |
|---|---|
client.run("rotat", { input }) |
Argument of type '"rotat"' is not assignable to parameter of type 'ToolId' |
params: { watermarkTxt: "DRAFT" } |
Object literal may only specify known properties, but 'watermarkTxt' does not exist、末尾は Did you mean to write 'watermarkText'? |
params: { angle: 45 }(rotate が受け付けるのは 90、180、270 のみ) |
Type '45' is not assignable to type …、続けて許される値 |
{ angle: 90 } や { watermarkText: "DRAFT", fontSize: 30 } のような正しい呼び出しはコンパイルが通る。数値フィールドは数値も文字列も受け付けるので、fontSize: 30 と fontSize: "30" は同じ意味だ。TypeScript を使わないプロジェクトでも同じエラーを実行時に受け取り、リクエストは送信されない。三つ目の例では Field "angle" of "rotate" must be one of: 90, 180, 270 となり、ツール名を間違えると unknown_tool が返って、メッセージに似た名前が並ぶ。
サーバーが拒否したら reason で分岐する
サーバーがリクエストを拒否すると、SDK は status、code、reason、problem.hint を持つ Pdf123Error を投げる。一つの code に複数の reason があり得るので、まず reason を調べ、無ければ code に戻る。よくある三つの失敗を、同じ入力でローカルに実行すると次のようになる。
| 入力 | status | code | reason | hint(原文は英語) |
|---|---|---|---|---|
| 暗号化された PDF、パスワード未指定 | 400 | bad_request |
password_required |
Provide the document password, or unlock the PDF first |
| パスワードが違う | 400 | bad_request |
wrong_password |
Check the password and try again |
| PDF ではない、または壊れたファイル | 400 | invalid_document |
invalid_pdf |
Upload a valid, undamaged PDF; you can try repairing it first |
import { Pdf123Client, Pdf123Error } from "@pdf123/sdk";
import { readFileInput } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const input = await readFileInput("locked.pdf");
try {
await client.run("compress", { input });
} catch (error) {
if (!(error instanceof Pdf123Error)) throw error;
console.error(error.status, error.code, error.reason, error.problem?.hint);
if (error.reason === "password_required") {
await client.run("compress", { input, password: "secret" });
}
}
パスワードが分かっているなら、単一ファイルに password を渡す。ロック解除と目的のツールは同じリクエストの中で行われる。壊れたファイルは、まずPDFの修復を試す。
「結果が無い」場合は区別が要る。PDFからCSVが PDF の中に表を見つけられなければ、サーバーは 204 を返し、SDK は code: "no_content" を投げる。理由は空の表エクスポート(204):その PDF には列が無い可能性が高いにある。条件付きフィルターのツールは、条件が偽であることを正常な結果として扱う。投げることはなく、matched: false と空の data を返す。
再試行、メモリ、キャンセルは呼び出し側の仕事
ネットワークエラーと 5xx 応答はそのまま投げられ、SDK が自分で再試行することはない。状態を変えるリクエストには、自分で決めた idempotencyKey を渡す。同じキーで再送すれば、もう一度処理せず最初の結果が返る。仕組みと上限はIdempotency-Key:PDF ジョブの安全な再試行にある。結果は丸ごとメモリに読み込まれ、ストリーミングはしない。入力も出力も大きいときは、同時に占めるメモリを数えておくこと。
キャンセルとタイムアウトは code の値が別々だ。一回の呼び出しで報告されるのは、先に起きた方だけだ。次の例では二つを別々の呼び出しで試し、どちらの分岐も実際に動くようにしてある。
import { Pdf123Client, Pdf123Error } from "@pdf123/sdk";
import { readFileInput } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const input = await readFileInput("a.pdf");
const controller = new AbortController();
setTimeout(() => controller.abort(), 10_000);
try {
await client.run("compress", { input, signal: controller.signal });
} catch (error) {
if (error instanceof Pdf123Error && error.code === "cancelled") { /* 自分でキャンセルした */ }
}
try {
await client.run("compress", { input, timeoutMs: 60_000 });
} catch (error) {
if (error instanceof Pdf123Error && error.code === "timeout") { /* timeoutMs を超えた。今回は signal なし */ }
}
中断シグナル(AbortSignal)が発動した時点で、リクエストは code: "cancelled" で reject される。タイムアウトを超えると code: "timeout" になる。各リクエストの既定のタイムアウトは 5 分で、クライアントを作るときに変えることも、呼び出しごとに timeoutMs で上書きすることもできる。分割アップロードでは、各リクエストがそれぞれ独立にそのタイムアウトを持つ。キャンセルが閉じるのはこの接続だけで、サーバーが処理を止める保証は無い。
一つのファイルが失敗しても残りは続き、コールバックは完了順に届く
単一ファイル用のツールに入力をまとめて渡すには runBatch を使う。一つのファイルが失敗しても、ほかは続行する。onResult は各ファイルが終わった瞬間に呼ばれるので完了順に動き、index は入力配列の中での位置だ。返される配列は常に入力順になる。バイト列はこのコールバックの中でディスクに書くこと。retainData: false は、コールバックが戻ると data を空にするので、ここで saveResult しなかった結果は失われる。実行が途中で中断されても、すでに書いたファイルは残る。
フォルダーに、正常な PDF が三つ、つまり a.pdf、b.pdf、暗号化された locked.pdf(パスワードは secret)と、数文字を打っただけの壊れたファイル broken.pdf があるとする。
import { Pdf123Client } from "@pdf123/sdk";
import { fileSource, saveResult } from "@pdf123/sdk/node";
const client = new Pdf123Client();
const results = await client.runBatch("compress", [
fileSource("a.pdf"), fileSource("broken.pdf"), fileSource("b.pdf"), fileSource("locked.pdf"),
], {
concurrency: 2,
passwordFor: (file) => (file.name === "locked.pdf" ? "secret" : undefined),
onResult: async (entry, index) => {
console.log(index, entry.input.name, entry.ok ? "ok" : entry.error.reason);
if (entry.ok) await saveResult(entry.result, { output: "out/" });
},
retainData: false,
});
既定では二つのファイルを同時に処理する。変えるには concurrency を使う。fileSource(path) は順番が来たときに初めてディスクからファイルを読むので、大きなファイルの大きなバッチでも、全部が一度にメモリに載ることはない。passwordFor により、ロックされたファイルとされていないファイルが混ざったバッチも一度で処理できる。上の四つのファイルをローカルで実行したとき、コールバックが受け取ったのは次のとおり。
1 broken.pdf invalid_pdf
0 a.pdf ok
2 b.pdf ok
3 locked.pdf ok
完了順は実行のたびに変わり得る。これはある一回の実行の様子にすぎない。retainData: false により、返される配列の data は空になる。成功したファイルは、上の saveResult によってすでに out/ に書かれている。runBatch に idempotencyKey を渡すと、各ファイルに実際に送られるキーは <key>:<index> になる。
分割アップロードは合計が 95 MiB を超えたときだけ
直接のリクエストボディは 100 MiB に制限され、それより大きいものは拒否される。詳しくは大きな PDF のアップロードが拒否されるとき:100 MiB のリクエストボディ上限と、誤った方向を指すエラーを参照。一つのリクエストに含まれる全ファイルの合計が 95 MiB を超えると、SDK は分割アップロードに切り替わり、client.run の呼び出しは変わらない。サーバーが一回のアップロードで受け付ける既定の上限は 500 MiB で、セルフホストするなら PDFX_UPLOAD_MAX_BYTES で調整する。
同じ操作を curl、MCP、コマンドラインで書いたものは同じ操作、四つのクライアント:ブラウザ、curl、MCP、pdfxを参照。あちらは四つの呼び出しを並べたもので、この記事は SDK だけを掘り下げている。npm 上のパッケージのページは @pdf123/sdk だ。