CJK was a 500. Generate does not count.
Executive Summary
CJK character encoding crashes and preview quota misaccounting have been fully resolved in production. Multibyte Japanese Kanji, Hiragana, Katakana, Chinese Hanzi, and Korean Hangul previously threw unhandled 500 exceptions in PDF-lib due to legacy WinAnsi (Windows-1252) font limitations during certificate stamping. Furthermore, preview generation (POST /api/v1/generate) was incorrectly decrementing monthly document allowances. Here is the technical root cause analysis, our dynamic font subsetting solution, and our transactional quota architecture.
Founder, Signbee
The Incident Report
Earlier this week, a software engineering lead evaluating Signbee for Japanese real estate contracts submitted a bug report outlining a complete workflow stoppage:
- A signing packet titled
秘密保持契約書(テスト送信)addressed to recipient山本 裕司(テスト)returned an unexpected HTTP status:500 {"error":"Failed to complete signing"}when the recipient executed their signature. - Calling
POST /api/v1/generateto preview Japanese contract markdown returned500 {"error":"Failed to generate document"}. - Sending the exact same contractual payload using standard ASCII characters succeeded in sub-200ms.
- Critically, every single 500 error incremented the user's monthly document count, burning their remaining free tier quota and locking their account at
5/5documents.
This was an unacceptable developer experience. Within hours, we diagnosed the root cause, implemented an isolated fix, deployed dynamic font subsetting, and credited the affected user.
Root Cause 1: The WinAnsi Encoding Trap in ISO 32000-1
To understand why the failure occurred, we must examine the internal typography architecture of the PDF specification (ISO 32000-1).
High-performance serverless document generators frequently rely on pdf-lib and its built-in "Standard 14 PostScript Fonts" (Helvetica, Helvetica-Bold, Times-Roman, Courier, Symbol, ZapfDingbats). The monumental advantage of the Standard 14 fonts is zero binary footprint: the font outlines are not embedded into the PDF file. Instead, the PDF reader (Adobe Acrobat, Preview, Chrome PDF Viewer) renders them using built-in system fonts. This produces ultralight PDFs under 5KB.
However, the Standard 14 fonts operate strictly under WinAnsi (Windows-1252) character encoding. WinAnsi is a single-byte (8-bit) encoding supporting 256 code points. It has zero native concept of multibyte UTF-8 code sequences:
| Character | Unicode Code Point | UTF-8 Byte Sequence | WinAnsi Support |
|---|---|---|---|
| A | U+0041 | 0x41 | Valid (0x41) |
| é | U+00E9 | 0xC3 0xA9 | Valid (0xE9) |
| 秘 (Kanji) | U+79D8 | 0xE7 0xA7 0x98 | Crash: WinAnsi cannot encode "秘" |
| 山 (Kanji) | U+5C71 | 0xE5 0xB1 0xB1 | Crash: WinAnsi cannot encode "山" |
When our signing certificate generator attempted to stamp the Certificate of Completion at the final stage of document signing, it executed:
// Helvetica only supports 8-bit WinAnsi
const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
// When data.title was "秘密保持契約書(テスト送信)", pdf-lib threw:
// Unhandled Exception: WinAnsi cannot encode "秘" (0x79d8)
page.drawText(`Document: "${data.title}"`, { font: helvetica });Because this exception was uncaught, the entire signing finalization route aborted mid-flight. The primary contract PDF had already been signed, but the Certificate of Completion could not be stamped, causing the API to return HTTP 500.
Font Architecture Comparison: Finding the Golden Ratio
Solving this required evaluating four distinct PDF rendering architectures. We needed full CJK compatibility, zero cold-start penalties on serverless runtimes, and minimal file sizes.
| Approach | Average PDF Size | Cold Start Latency | Memory Footprint | CJK Fidelity |
|---|---|---|---|---|
| Standard 14 (WinAnsi) | < 5 KB | < 15 ms | < 20 MB | Zero (Crashes on CJK) |
| Full TTF/OTF Embedding | 18 MB – 36 MB | 1,200 ms | 180 MB | Complete |
| Headless Chromium (Puppeteer) | 350 KB – 1.2 MB | 2,500 ms (cold boot) | 512 MB+ | Complete (Web engine) |
| Dynamic Fontkit Subsetting (Signbee) | 25 KB – 45 KB | < 65 ms | < 45 MB | Universal (Vector Subset) |
Full font embedding was unviable: sending a 30MB PDF for a 2-page contract creates unacceptable email attachment sizes and bandwidth costs. Headless Chromium introduced 2.5-second cold starts and memory spikes.
The optimal path was dynamic font subsetting via @pdf-lib/fontkit. By embedding Noto Sans CJK and subsetting only the exact characters present in the document title, signer names, and markdown body, the resulting PDF remains under 45KB while achieving 100% typographic fidelity.
The Dual Implementation: Dynamic Subsetting + Safe Sanitization
We implemented a defense-in-depth architecture across both our document compiler and Certificate of Completion stamper:
1. Dynamic Fontkit Subsetting Pipeline
import { PDFDocument } from "pdf-lib";
import fontkit from "@pdf-lib/fontkit";
import fs from "fs/promises";
import path from "path";
let cachedFontBytes: Uint8Array | null = null;
export async function embedMultilingualFont(pdfDoc: PDFDocument) {
// Register fontkit to enable TTF/OTF dynamic glyph subsetting
pdfDoc.registerFontkit(fontkit);
if (!cachedFontBytes) {
const fontPath = path.join(process.cwd(), "fonts", "NotoSansJP-Regular.otf");
cachedFontBytes = await fs.readFile(fontPath);
}
// Embed font with subsetting enabled
// pdf-lib will parse the document text and include only necessary glyphs
const customFont = await pdfDoc.embedFont(cachedFontBytes, { subset: true });
return customFont;
}2. WinAnsi Sanitization Fallback
For standard English contracts that do not require CJK embedding, we keep the PDF file size under 5KB using Helvetica, protected by a zero-crash sanitization filter:
export function sanitizeForWinAnsi(text: string): string {
if (!text) return "";
return text
// Replace typographic curved quotes with ASCII standard equivalents
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
// Replace em-dash and en-dash with standard hyphens
.replace(/[\u2013\u2014]/g, "-")
// Replace horizontal ellipsis
.replace(/\u2026/g, "...")
// Replace non-breaking spaces with standard spaces
.replace(/\u00A0/g, " ")
// Filter any remaining non-Latin1 characters to prevent unhandled exceptions
.replace(/[^\x00-\xFF]/g, "?");
}Root Cause 2: Quota Misaccounting Remediated
The second issue was equally critical: why did POST /api/v1/generate consume monthly quotas, and why were quotas burned on 500 crashes?
In our early architecture, checkAndIncrementDocCount(userId) was executed as an inline middleware at the very entry point of API route handlers. If an API consumer called /api/v1/generate to test markdown rendering, their quota was incremented before document compilation began. If the compiler crashed with an error, the increment had already been committed.
Signbee Endpoint Accounting & Quota Governance Matrix
| Endpoint | Purpose | Authentication | Quota Impact | On 4xx/5xx Error |
|---|---|---|---|---|
| POST /api/v1/generate | Unsigned PDF preview rendering | Bearer API Key | 0 docs (Always Free) | No quota impact |
| POST /api/v1/send | Two-party signing contract creation | Bearer API Key | -1 doc on 200 OK | Zero impact (Rollback) |
| GET /api/v1/documents/{id} | Inspect document & signing status | Bearer API Key | 0 docs (Stateless Query) | No quota impact |
| POST /api/v1/verify | Audit certificate cryptographic check | Public / Optional Auth | 0 docs (Public Utility) | No quota impact |
We completely decoupled POST /api/v1/generate from quota tracking: previews are completely free and unmetered. For POST /api/v1/send, quota deduction now executes inside an atomic database transaction only after successful PDF compilation and email invitation dispatch.
Testing CJK Dispatch via API
Developers and autonomous AI agents can test multilingual contract execution with Japanese, Chinese, or Korean metadata immediately using standard cURL or Python:
import requests
import os
SIGNBEE_API_KEY = os.environ.get("SIGNBEE_API_KEY")
payload = {
"title": "秘密保持契約書(NDA)",
"markdown": """# 秘密保持契約書
甲(委託者)及び乙(受託者)は、本契約に基づき開示される秘密情報の取扱いに関し、以下のとおり合意する。
| 条項 | 内容 |
| :--- | :--- |
| 第1条 (定義) | 本契約における秘密情報とは、書面または電磁的記録により開示される技術上・営業上の情報をいう。 |
| 第2条 (秘密保持) | 乙は甲の事前の書面による承諾なしに第三者へ秘密情報を開示してはならない。 |
| 第3条 (有効期間) | 本契約の有効期間は締結日より2年間とする。 |
""",
"recipient_name": "山本 裕司",
"recipient_email": "yuji.yamamoto@example.jp"
}
response = requests.post(
"https://signb.ee/api/v1/send",
headers={
"Authorization": f"Bearer {SIGNBEE_API_KEY}",
"Content-Type": "application/json"
},
json=payload
)
print(f"Status Code: {response.status_code}")
print(f"Response: {response.json()}")
assert response.status_code == 200Frequently Asked Questions
Why did Japanese, Chinese, and Korean characters trigger an unhandled 500 error?
In standard PDF-lib implementations, the default standard 14 PostScript Type 1 fonts (such as Helvetica, Times Roman, and Courier) do not embed binary font files into the document. Instead, they rely on built-in OS font renderers constrained to the 8-bit WinAnsi (Windows-1252) character encoding. WinAnsi can only encode Latin-1 characters (character codes 0 to 255). When our backend Certificate of Completion generator attempted to stamp a recipient name or document title containing CJK glyphs (like 秘密保持契約書 or 山本 裕司), PDF-lib encountered unicode code points exceeding 0x00FF and threw an unhandled encoding exception, terminating the request with HTTP 500.
How does dynamic font subsetting resolve multibyte CJK rendering without massive PDF files?
Embedding complete Asian font files like Noto Sans CJK or Source Han Sans directly into every generated document adds between 15MB and 35MB of binary data per PDF, which is unacceptable for fast API delivery. We integrated @pdf-lib/fontkit to perform dynamic glyph subsetting at runtime. When a contract markdown document or audit certificate is compiled, the engine scans the exact UTF-8 character string, extracts only the required vector glyph outlines and font metric tables, and packages a micro-subset into the PDF binary. This maintains universal CJK rendering fidelity while keeping the overall PDF file size well under 50KB.
Does calling POST /api/v1/generate count toward monthly document signing quotas?
No. The POST /api/v1/generate endpoint is strictly dedicated to stateless preview rendering and local validation. It allows developers and AI agents to test markdown layouts, verify custom CSS typography, and preview document formatting without consuming your monthly signing allowance. Only POST /api/v1/send requests that establish a verifiable two-party signing ceremony and dispatch signature request emails decrement your monthly document quota.
What happens to account quotas if an API request fails with a 4xx or 5xx server error?
Failed API requests never decrement your document quota. We refactored quota deduction to follow transactional database isolation. Quota checks verify that your account has remaining document allowances at the start of the transaction, but the quota counter is only decremented after the signing ceremony record has been successfully committed to the database and the initial recipient invitation has been dispatched. Any failure encountered during PDF generation, font subsetting, or email delivery triggers an immediate database rollback with zero quota penalty.