September 1, 2026·11 min read·Post-Mortem & Architecture

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.

Michael Beckett
Michael Beckett

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/generate to preview Japanese contract markdown returned 500 {"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/5 documents.

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:

CharacterUnicode Code PointUTF-8 Byte SequenceWinAnsi Support
AU+00410x41Valid (0x41)
éU+00E90xC3 0xA9Valid (0xE9)
秘 (Kanji)U+79D80xE7 0xA7 0x98Crash: WinAnsi cannot encode "秘"
山 (Kanji)U+5C710xE5 0xB1 0xB1Crash: WinAnsi cannot encode "山"

When our signing certificate generator attempted to stamp the Certificate of Completion at the final stage of document signing, it executed:

src/lib/signing-certificate.ts (Vulnerable Code)
// 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.

ApproachAverage PDF SizeCold Start LatencyMemory FootprintCJK Fidelity
Standard 14 (WinAnsi)< 5 KB< 15 ms< 20 MBZero (Crashes on CJK)
Full TTF/OTF Embedding18 MB – 36 MB1,200 ms180 MBComplete
Headless Chromium (Puppeteer)350 KB – 1.2 MB2,500 ms (cold boot)512 MB+Complete (Web engine)
Dynamic Fontkit Subsetting (Signbee)25 KB – 45 KB< 65 ms< 45 MBUniversal (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

src/lib/cjk-font-loader.ts
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:

src/lib/sanitize-winansi.ts
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

EndpointPurposeAuthenticationQuota ImpactOn 4xx/5xx Error
POST /api/v1/generateUnsigned PDF preview renderingBearer API Key0 docs (Always Free)No quota impact
POST /api/v1/sendTwo-party signing contract creationBearer API Key-1 doc on 200 OKZero impact (Rollback)
GET /api/v1/documents/{id}Inspect document & signing statusBearer API Key0 docs (Stateless Query)No quota impact
POST /api/v1/verifyAudit certificate cryptographic checkPublic / Optional Auth0 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:

Python — Multilingual Contract Dispatch via Signbee API
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 == 200

Frequently 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.