# Checking Which Characters a Font Supports in JavaScript: Reading cmap for Glyph Coverage and Rendered Width, Then Catching Variant Kanji, Vendor Characters and Holiday-Blocked Delivery Dates at Order Time

> How to tell whether a font contains a character in JavaScript: why document.fonts.check() fails, reading cmap and hmtx for glyph coverage and width, Unicode traps, and holiday-aware dates.

- Published: 2026-09-25
- Author: 友田 陽大
- Tags: TypeScript, Frontend, Unicode, Fonts, Type Safety, Security, Indie Development
- URL: https://tomodahinata.com/en/blog/font-glyph-coverage-check-javascript-cmap-guide
- Category: Frontend
- Pillar guide: https://tomodahinata.com/en/blog/nextjs-16-app-router-cache-components-data-fetching

## Key points

- Whether a font has a given character can only be answered by reading the font file's cmap table. By spec, `document.fonts.check()` returns true even when the text will be drawn with a fallback font, so it cannot tell you whether a glyph exists.
- Reading cmap (format 4 / 12) and hmtx gives you both glyph coverage and advance widths. Collapse consecutive code points with the same advance into ranges and Noto Sans JP's 16,732 code points fit in 5,246 ranges you can binary-search.
- NFC is not harmless. Many CJK compatibility ideographs (for example U+FA19 神) become a different code point under NFC (U+795E), which erases the glyph variant the customer asked for. Check glyphs on the NFC form, and report separately that normalization changed the text.
- Count characters in grapheme clusters with Intl.Segmenter, and measure width from per-code-point advances. Half-width ﾀﾞ is one grapheme but two half-width advances wide; never mix the units.
- Do not derive Japanese public holidays from rules; import the Cabinet Office CSV. Substitute holidays and sandwiched 'citizens' holidays' depend on neighbouring dates. Order data never leaves the browser, and the CSP connect-src directive enforces that.

---

Engraved tumblers, embroidered towels, pens with a name laser-etched on the barrel. For personalized goods like these, the text the buyer types is exactly what gets produced. The trouble is that **when the order comes in, nobody knows yet whether it can actually be made**:

- The production font does not contain the character (髙, 𠮷, an emoji)
- The text uses a script the process cannot handle (embroidery limited to Latin letters, for instance)
- The character and line counts are within limits, but **the set text is wider than the area that can be engraved**
- Given the production lead time and a run of public holidays, **the requested delivery date was never achievable**

If the workshop only finds out once production has started, the result is a message to the buyer, a cancellation, or a one-star review.

This article shows how to **decide all of this at the moment the order data arrives, entirely inside the browser**. The worked example is the rule engine of [Tsukurumae](/labs/tsukurumae), a checking tool I am building for Japanese online shops that sell personalized goods. The hardest part is **reliably telling, in JavaScript, whether a font contains a character**, so most of the article is about reading a font's cmap table. After that come Unicode normalization, vendor-specific characters, and holiday-aware delivery dates.

A note on scope: Tsukurumae's interface is in Japanese and it targets Japanese marketplaces (its CSV audit reads Rakuten order exports, and its delivery math uses Japanese public holidays). The techniques below apply to any language, but the examples are Japanese because the product is.

> **Before you start**: the code excerpts are trimmed from Tsukurumae's repository (TypeScript, private) to what each point needs. The engine is not published on npm, so you cannot install it. How every number was counted is listed in "Where the numbers come from" at the end.

## 0. The short answer: what to check, and with what

Here is the decision table up front.

| What you need to know | Use | Do not use |
| --- | --- | --- |
| Whether the font has a glyph for the character | The font file's **cmap table** (format 4 / 12) | `document.fonts.check()` (returns true when a fallback font will draw it) |
| Width once engraved | **Sum of hmtx advance widths** × font size ÷ unitsPerEm | On-screen `measureText` (fallback glyphs sneak in unnoticed) |
| Character count | **Grapheme clusters** (`Intl.Segmenter`) | `string.length` (UTF-16 code units) |
| Characters that look identical but differ in code point | Compare under **NFC** and warn about differences | Automatic NFKC (① becomes 1, ㈱ becomes (株)) |
| Vendor-specific ("platform-dependent") characters | A table of **code points that exist only in CP932's vendor extension rows** | A vague "anything outside JIS levels 1 and 2" rule |
| Whether the requested date is achievable | Business-day arithmetic + **the Cabinet Office holiday CSV** | Deriving holidays from rules yourself |

The engine is **a set of pure TypeScript functions with no I/O and no LLM**. It has 10 rules at three severities.

| Severity | Rules | What they detect |
| --- | --- | --- |
| block (only what can be decided mechanically) | `glyph-missing` / `method-charset` / `length-exceeded` / `delivery-impossible` | No glyph / a script the process cannot produce / too many characters, lines, or too wide / requested date before the earliest possible arrival |
| warn | `hepburn` / `option-mismatch` / `remark-directive` / `spacing-anomaly` / `compat-char` | Deviations from Hepburn romanization / options that contradict the text / instructions buried in the order notes / stray spaces or variation selectors / vendor characters and CJK compatibility ideographs |
| info | `name-dictionary` | A name not in the name dictionary |

Only things that are **mechanically certain** — "not in the font", "over the limit" — are allowed to block. A typo such as 裕子 versus 祐子 cannot be judged by a machine, so the engine does not try. That line is what makes a checking tool trustworthy.

---

## 1. Why document.fonts.check() cannot answer the question

Search for "check if a font supports a character in JavaScript" and `document.fonts.check()` comes up a lot. It is **not an API for glyph coverage**.

The CSS Font Loading spec defines `check()` as telling you whether you can "safely" render some text with a font list, "such that it won't cause a 'font swap' later". It then spells out two non-obvious cases:

- If the fonts exist but **their unicode-range does not cover the text**, it returns **true**, because the text will be drawn in the fallback font and nothing needs loading.
- If **none of the named fonts exist** (say, a typo in the name), it also returns **true**, for the same reason.

A missing glyph is simply drawn with a fallback font, so **`check()` returns true**.

Another common trick is to compare `measureText()` widths between the target font and a fallback. For Japanese kanji, almost every font uses a full-width (1em) advance, so the widths often match and tell you nothing. More fundamentally, **the font used by the engraving machine may not be installed in the browser at all**. What we want to know is not how the text looks on screen, but **whether it can be produced with that specific font**.

So the check has to **read the font file itself**.

---

## 2. Reading only cmap and hmtx: glyph coverage and advance widths

### 2-1. Only a handful of tables matter

An OpenType file (TrueType included) is a collection of tables. For coverage and width you need just these:

| Table | Contents | Value used here |
| --- | --- | --- |
| `cmap` | Character code → glyph ID mapping | Whether the code point has a glyph |
| `hmtx` | Per-glyph advance width | Rendered width |
| `hhea` | Horizontal header | `numberOfHMetrics` (how many entries hmtx has) |
| `head` | Font header | `unitsPerEm` (units per em) |
| `maxp` | Maximum profile | `numGlyphs` (valid glyph ID range) |

Outlines (`glyf` / `CFF`) and glyph substitution (`GSUB`) are never read. General-purpose font libraries that interpret them are capable but heavy for this job, so Tsukurumae uses its own parser that reads only these five tables (`packages/verify/src/lib/font.ts`, 578 lines).

### 2-2. Picking a cmap subtable

cmap holds one subtable per platform/encoding pair. Only Unicode encodings are candidates, and **format 12, which covers code points beyond the BMP (such as 𠮷), is preferred over format 4**.

```ts
// packages/verify/src/lib/font.ts (excerpt)
/** Only encodings that mean Unicode. Macintosh (1) and Windows Symbol (3,0) are different code systems. */
function isUnicodeEncoding(platformId: number, encodingId: number): boolean {
  if (platformId === 0) return true
  return platformId === 3 && (encodingId === 1 || encodingId === 10)
}

/** Prefer format 12 (covers beyond the BMP) over format 4; on a tie, prefer Windows. */
function selectSubtable(view: DataView, cmap: TableRecord): Subtable {
  // …walk the subtable list…
  if (!isUnicodeEncoding(platformId, encodingId)) continue
  const format = u16(view, subtableAt, 'cmap サブテーブルの format')
  if (format !== 4 && format !== 12) continue
  const score = (format === 12 ? 2 : 0) + (platformId === 3 ? 1 : 0)
  if (best === undefined || score > best.score) best = { at: subtableAt, format, score }
  // …
}
```

With a font that only has format 4, every character outside the BMP is reported **missing**. That is not a bug; it is what the font actually contains.

### 2-3. Glyph ID 0 means "missing"

This one is easy to miss. cmap maps code points to glyph IDs, but **glyph ID 0 is `.notdef`, the reserved "no glyph" value**. A code point listed in cmap but pointing at 0 is treated as missing. IDs at or above `numGlyphs` are broken references and are treated as missing too.

```ts
// packages/verify/src/lib/font.ts (excerpt)
/**
 * Whether a glyph ID is usable. 0 is the reserved "no glyph" value; >= numGlyphs is a broken reference.
 * Both count as "not covered" (reporting a missing character as present makes the engraving fail).
 */
function isUsableGlyph(glyphId: number, numGlyphs: number): boolean {
  return glyphId > 0 && glyphId < numGlyphs
}
```

Per the OpenType spec, the last format 4 segment must have `startCode = endCode = 0xFFFF`, and that code point normally maps to the missing glyph (ID 0). Some general-purpose libraries enumerate code points without looking at the glyph ID, so U+FFFF appears "covered". According to a comment in the source, comparing this parser with fontkit across the 371 fonts bundled with macOS, 255 fonts differed — and in every one of them the only difference was that single U+FFFF.

### 2-4. Reading format 12 without trusting the declared count

Format 12 is a simple list of 12-byte groups: start code point, end code point, start glyph ID. But a font is **an untrusted byte sequence supplied by the user**. Loop over the declared count blindly and a truncated or crafted file will hang you.

```ts
// packages/verify/src/lib/font.ts (excerpt)
function readFormat12(args: CmapReadArgs): void {
  const { view, at, end, metrics, numGlyphs, acc } = args
  const groups = u32(view, at + 12, 'cmap format 12 の nGroups')
  const groupsAt = at + 16
  // Never size anything from a count. Confirm the declared groups actually exist in the table first.
  if (groupsAt > end || groups > (end - groupsAt) / 12) {
    throw new FontError('truncated', 'cmap format 12 のグループ数が実体より多く宣言されています。')
  }
  let previousEnd = -1
  for (let index = 0; index < groups; index += 1) {
    const groupAt = groupsAt + index * 12
    const groupStart = u32(view, groupAt, 'cmap format 12 の startCharCode')
    const groupEnd = u32(view, groupAt + 4, 'cmap format 12 の endCharCode')
    const startGlyph = u32(view, groupAt + 8, 'cmap format 12 の startGlyphID')
    if (groupStart > groupEnd || groupEnd > MAX_UNICODE) {
      throw new FontError('malformed', 'cmap format 12 のグループが Unicode の範囲を越えています。')
    }
    if (groupStart <= previousEnd) {
      throw new FontError('malformed', 'cmap format 12 のグループが昇順に並んでいません。')
    }
    previousEnd = groupEnd
    for (let codepoint = groupStart; codepoint <= groupEnd; codepoint += 1) {
      const glyphId = startGlyph + (codepoint - groupStart)
      if (!isUsableGlyph(glyphId, numGlyphs)) continue
      append(acc, codepoint, advanceOf(metrics, glyphId))
    }
  }
}
```

**Enforcing strictly ascending, non-overlapping groups** does two jobs. It is the precondition for the binary search used later. It also guarantees that **each code point is processed at most once**, which caps the total number of iterations at the size of the Unicode space (1,114,112). Input validation doubles as the iteration limit; no separate counter is needed.

Every read goes through a bounds check (`need()`) first, and failures are never thrown to the caller. They come back typed as `{ ok: false, error: { kind, message } }`, with `kind` values such as `'woff2'`, `'truncated'` and `'unsupported-cmap'`. The UI can therefore show a message per cause that tells the user **what to do next** ("WOFF2 is not supported; please provide the TTF or OTF").

### 2-5. Advance widths: glyphs past numberOfHMetrics reuse the last value

hmtx lists advance widths for the first `numberOfHMetrics` glyphs. The spec says every glyph after that **shares the last advance width**. Japanese fonts, where every kanji has the same width, use this to stay small.

```ts
// packages/verify/src/lib/font.ts (excerpt)
function advanceOf(metrics: Metrics, glyphId: number): number {
  const index = glyphId < metrics.numberOfHMetrics ? glyphId : metrics.numberOfHMetrics - 1
  const at = metrics.hmtx.offset + index * 4
  if (at + 2 > metrics.hmtx.offset + metrics.hmtx.length) {
    throw new FontError('truncated', 'hmtx テーブルが送り幅の件数に足りていません。')
  }
  return u16(metrics.view, at, 'hmtx.advanceWidth')
}
```

### 2-6. Collapsing "code point × advance" into ranges

The result is an array of `[start code point, end code point, advance]`. **Consecutive code points with the same advance are merged into one range.**

```ts
// packages/verify/src/lib/font.ts (excerpt)
function append(acc: Accumulator, codepoint: number, advanceUnits: number): void {
  const last = acc.ranges[acc.ranges.length - 1]
  if (last?.[1] === codepoint - 1 && last[2] === advanceUnits) {
    last[1] = codepoint
  } else {
    if (acc.ranges.length >= MAX_RANGES) {
      throw new FontError('too-large', `フォントの文字コード表が複雑すぎます（範囲 ${MAX_RANGES} 件を超過）。`)
    }
    acc.ranges.push([codepoint, codepoint, advanceUnits])
  }
  acc.count += 1
}
```

Because every kanji in a Japanese font has the same advance, this compresses well. The bundled Noto Sans JP table goes from **16,732 code points to 5,246 ranges**, 114,695 bytes as JSON. `MAX_RANGES` (100,000) stops a crafted font that changes the advance at every code point from exhausting memory.

Here is the parser run against real fonts on my Mac (Node 24.16.0, median of 21 runs):

| Font | File size | cmap format | Code points | Ranges | Parse time |
| --- | --- | --- | --- | --- | --- |
| Arial Unicode MS | 22.2 MB | format 4 | 38,917 | 3,266 | 0.38 ms |
| Hiragino Sans W3 (first of 4 fonts in the TTC) | 7.5 MB | format 12 | 13,861 | 5,685 | 0.65 ms |

What drives the cost is **the number of mapped code points, not the file size**. Loading a font of tens of megabytes does not freeze the page.

### 2-7. Lookup is a binary search

The ranges are sorted, so a lookup is a binary search, and one search gives you both coverage and advance.

```ts
// packages/verify/src/lib/cmap.ts (excerpt)
function findRange(table: CmapTable, cp: number): CmapRange | undefined {
  let lo = 0
  let hi = table.ranges.length - 1
  while (lo <= hi) {
    const mid = (lo + hi) >>> 1
    const range = table.ranges[mid]
    if (range === undefined) return undefined
    if (cp < range[0]) hi = mid - 1
    else if (cp > range[1]) lo = mid + 1
    else return range
  }
  return undefined
}

export function hasGlyph(table: CmapTable, cp: number): boolean {
  return findRange(table, cp) !== undefined
}

/** Rendered width in mm. Missing glyphs are estimated at 1em. Variation selectors are zero-width. */
export function measureWidthMm(table: CmapTable, text: string, fontSizeMm: number): number {
  let units = 0
  for (const ch of text) {
    const cp = ch.codePointAt(0)
    if (cp === undefined || isVariationSelector(cp)) continue
    units += advanceUnits(table, cp) ?? table.unitsPerEm
  }
  return (units / table.unitsPerEm) * fontSizeMm
}
```

`for (const ch of text)` iterates by **code point**, not by UTF-16 code unit. Loop with `text[i]` or `charCodeAt` and a surrogate pair such as 𠮷 (U+20BB7) splits in two, and you end up looking up code points that do not exist.

### 2-8. Same text, different font, different answer

The same inputs checked against three fonts' cmaps:

| Input | Noto Sans JP | Arial Unicode MS | Hiragino Sans W3 |
| --- | --- | --- | --- |
| 髙 (U+9AD9) | yes | yes | yes |
| 𠮷 (U+20BB7) | yes | **no** | yes |
| ① (U+2460) | yes | yes | yes |
| 🎂 (U+1F382) | **no** | **no** | **no** |
| Width of "Yamada Taro" set at 5 mm | 30.73 mm | 30.29 mm | 33.01 mm |

Hiragino and Arial Unicode differ by 2.7 mm on the same Latin text. On a product whose engraving area is 32 mm wide, the font alone decides between "fits" and "overflows". **The check is meaningless unless it uses the production font's cmap.** Tsukurumae's engine picks the cmap per product through its settings (`ProductProfile.font`).

This width is **a straight sum of advances**. It ignores kerning (`GPOS`) and proportional alternates (`palt`). Orders right at the limit are expected to get a final check in the engraving machine's own preview.

---

## 3. Unicode traps: normalization, variant kanji and vendor characters

A correct glyph check still gives wrong answers if **the string being checked** is not what you think it is. These are the characters that actually cause trouble in personalized orders:

| Input | Code points | After NFC | After NFKC | How the engine treats it |
| --- | --- | --- | --- | --- |
| カ + combining voiced mark | U+30AB U+3099 | ガ (U+30AC) | ガ | Composed by NFC before checking; warns that it arrived decomposed |
| 神 (CJK compatibility ideograph) | U+FA19 | **神 (U+795E)** | 神 (U+795E) | Warns that NFC changed the character |
| 﨑 | U+FA11 | unchanged | 﨑 | Warns: exists only in CP932's vendor extensions |
| 髙 | U+9AD9 | unchanged | unchanged | Warns as a vendor character, and that it is easily confused with 高 |
| ① | U+2460 | unchanged | **1** | Warns as a vendor character; offers "1" as a suggestion |
| ㈱ | U+3231 | unchanged | **(株)** | Same as above |
| ﾀﾞ (half-width) | U+FF80 U+FF9E | unchanged | ダ | One grapheme, but two half-width advances wide |
| 葛 + IVS | U+845B U+E0100 | unchanged | unchanged | Selector stripped, checked as 葛; warns that a variant was requested |
| 🎂 | U+1F382 | unchanged | unchanged | Not in Noto Sans JP, so it blocks |

### 3-1. NFC is not harmless: CJK compatibility ideographs

"Normalize to NFC and you're safe" is a common belief, but NFC changes characters too. Most **CJK compatibility ideographs** (U+F900–FAFF and U+2F800–2FA1F) have **singleton canonical decompositions**, and UAX #15 explains that singletons are never recomposed.

Counting the U+F900–FAFF block in Node 24, 460 characters turn into a different code point under NFC and 12 stay the same. Those 12 (U+FA0E, FA0F, FA11 and so on) sit in the "compatibility" block by name but behave as unified ideographs; 﨑 (U+FA11) is one of them.

The problem is that compatibility ideographs are often used **precisely to keep a glyph difference**. U+FA19 神 may be entered to request the older form of the left-hand radical, and normalizing it to U+795E throws that request away. To represent these glyphs without losing them to normalization, Unicode defines **standardized variation sequences** (listed in StandardizedVariants.txt, for example `795E FE00; CJK COMPATIBILITY IDEOGRAPH-FA19;`).

The engine's policy is **never to hide that a character changed**:

1. The blocking checks (glyph, script, length) run on **the NFC form**.
2. The fact that normalization changed something is reported separately by the `compat-char` rule, which **looks at the raw string and emits a warning**.

```ts
// packages/verify/src/lib/text.ts (excerpt)
/**
 * Normalize into the form the blocking rules (glyph, script, length) look at.
 * - NFC (composes combining voiced marks etc.; compat-char keeps warning on the raw string)
 * - Tabs become spaces; other control and zero-width characters are removed (spacing-anomaly warns)
 * - Variation selectors are removed (the base character is what gets produced; spacing-anomaly warns)
 */
export function normalizeForBlockRules(line: string): string {
  return line.normalize('NFC').replace(/\t/g, ' ').replace(CONTROL_CHARS_G, '').replace(VARIATION_SELECTORS_G, '')
}
```

"Normalize, then check" and "tell the shop what normalization lost" are **two separate jobs, and you need both**.

### 3-2. Never apply NFKC automatically

NFKC turns ① into "1", ㈱ into "(株)", and full-width Latin into half-width. That is handy for search keys, but **applied to the text to be engraved, it produces something other than what the buyer ordered**. UAX #15 says it directly: "Normalization Forms KC and KD must not be blindly applied to arbitrary text."

The engine only offers the NFKC result as a **suggested replacement**:

```ts
// packages/verify/src/lib/compat.ts (excerpt)
/** Offer a replacement only when NFKC actually changes the character (vendor characters, compatibility ideographs). */
function nfkcHint(char: string): { suggestion?: string } {
  const nfkc = char.normalize('NFKC')
  return nfkc === char ? {} : { suggestion: nfkc }
}
```

### 3-3. Variation selectors are zero-width and have no glyph

To pin down the glyph of characters such as 葛 or 辻, a **variation selector** (VS1–16 at U+FE00–FE0F, or the IVS range U+E0100–E01EF) sometimes comes along. They slip in when someone picks a variant from the Windows IME candidate list, or pastes from Word.

A selector is a **zero-width code point** that selects a glyph for the preceding character. It has no glyph of its own, so looking it up in cmap would wrongly report "missing glyph". The engine therefore:

- **Strips selectors before** the glyph, script and length checks (the question is whether the base character can be produced)
- Treats them as **zero-width** in the width calculation (`isVariationSelector` in `measureWidthMm`)
- Has the `spacing-anomaly` rule **warn with the code points spelled out** (the glyph barely changes on screen, so without the code points the shop cannot see the difference)

Whether the production font supports that particular IVS (cmap format 14) is not checked today. That is a limitation worth stating plainly.

### 3-4. Define "vendor characters" as code points only in CP932's vendor rows

"Platform-dependent character" (機種依存文字) is a fuzzy term. The engine builds its table from **CP932.TXT**, the mapping for Windows code page 932 published by Unicode, taking only the **code points that exist solely in NEC special characters (row 13), NEC-selected IBM extensions (rows 89–92) and IBM extensions (rows 115–119)**. Characters that also appear in the standard JIS X 0208 rows are excluded, which leaves **447 characters** — ①, Ⅱ, ㈱, 髙, 﨑 and so on.

When a Shift_JIS order CSV is read with the browser's `TextDecoder('shift_jis')`, the WHATWG Encoding Standard's decoder handles these vendor extensions too. In practice 0x87 0x40 decodes to ① (U+2460), 0xFB 0xFC to 髙 (U+9AD9), and 0x81 0x60 to the full-width tilde ～ (U+FF5E). **A CSV that decodes without mojibake says nothing about whether the machine or the font can handle those characters.**

### 3-5. Count in graphemes, measure in code points

Enforce a "max 10 characters" rule with `string.length` and 𠮷 counts as 2 and 👨‍👩‍👧 as 8. The engine counts **grapheme clusters** (what a reader perceives as one character, per UAX #29) with `Intl.Segmenter`.

```ts
// packages/verify/src/lib/codepoints.ts (excerpt)
export function countChars(text: string): number {
  if (graphemeSegmenter === undefined) return charsOf(text).length
  let n = 0
  for (const _ of graphemeSegmenter.segment(text)) n += 1
  return n
}
```

Watch out for **half-width voiced marks**. Half-width ﾀﾞ (U+FF80 U+FF9E) is **one grapheme**, because U+FF9E extends the preceding character (measured: `countChars('ﾔﾏﾀﾞ')` is 3). Its width, however, is two half-width advances. The character limit is checked in graphemes and the width limit in per-code-point advances, and **the two units are never mixed**.

---

## 4. Holiday-aware delivery dates: keep a table, not rules

### 4-1. Computing the earliest arrival

An order is "impossible" by this arithmetic:

- **Acceptance date**: orders after the daily cutoff roll to the next day; if that is not a business day, move to the next one
- **Ship date**: acceptance date + production lead time (business days)
- **Earliest arrival**: ship date + transit days (calendar days; parcel carriers deliver on weekends and holidays)
- **Block if the requested date is before the earliest arrival**

Here is the engine's `estimateArrival` run just before Japan's Golden Week in 2026: noon cutoff, 3 business days of production, 2 days in transit, ordered Tuesday 28 April at 13:30, delivery requested for 5 May.

| Step | Date | Why |
| --- | --- | --- |
| Acceptance | Thu 4/30 | Past noon, so it rolls to 4/29 — Shōwa Day, a holiday — and then to the next business day |
| Production day 1 | Fri 5/1 | |
| Production day 2 | Thu 5/7 | 5/2–5/6 are the weekend plus holidays |
| Ship date | Fri 5/8 | Production day 3 |
| Earliest arrival | Sun 5/10 | 2 days in transit |

The request was 5/5, so the order is `delivery-impossible` (block). The shop sees the working, not just the verdict:

```text
希望到着日 2026-05-05 は最短到着可能日 2026-05-10 より前です（受付基準日 2026-04-30、製作 3 営業日 → 出荷可能日 2026-05-08、配送 2 日）
```

(In English: "Requested arrival 2026-05-05 is before the earliest possible arrival 2026-05-10 (accepted 2026-04-30, 3 business days of production → ships 2026-05-08, 2 days in transit)".) **Showing every intermediate date** lets the shop check the result for itself. A bare "this won't make it" leaves them only two choices: trust it or ignore it.

### 4-2. Why not derive holidays from the rules

5/6 is off because 5/3 (Constitution Day) fell on a Sunday, making it a **substitute holiday**. The Public Holiday Act says that when a national holiday falls on a Sunday, "the nearest day after it that is not a national holiday" becomes a holiday. And 22 September 2026 becomes a "**citizens' holiday**" because the day before (Respect for the Aged Day) and the day after (Autumnal Equinox Day) are both national holidays.

On top of that, **the equinox holidays have no fixed dates in the law**. The statute only says "the vernal equinox day" and "the autumnal equinox day"; the actual dates come from astronomical calculation.

Importing **the holiday CSV published by the Cabinet Office** (`syukujitsu.csv`) is more reliable than implementing all of that. The engine bundles 75 entries covering 2024–2027, and **any calculation that reaches outside those years says so in the result** ("the holiday data does not cover the whole period, so business-day math is an estimate"). Silently counting uncovered days as weekdays would look like full coverage when it is not.

### 4-3. Integer day numbers instead of Date

Dates are handled not with `Date` but as **integer day numbers since 1970-01-01** (Howard Hinnant's `days_from_civil`). `new Date('2026-05-06')` is parsed as UTC, while `getDate()` answers in the runtime's local time zone, so the same code can be a day off between a UTC server and a JST browser.

```ts
// packages/verify/src/lib/date.ts (excerpt)
/** n business days after the start (rounded to a business day). n = 0 returns the start itself. */
export function addBusinessDays(
  date: CivilDate,
  n: number,
  holidays: ReadonlySet<string>,
  businessWeekdays?: readonly number[],
): CivilDate {
  let cur = nextBusinessDay(date, holidays, businessWeekdays)
  for (let remaining = n; remaining > 0; remaining -= 1) {
    cur = nextBusinessDay(addCalendarDays(cur, 1), holidays, businessWeekdays)
  }
  return cur
}
```

`nextBusinessDay` gives up after 366 iterations, so a corrupted holiday list or an out-of-range business weekday such as `[7]` cannot cause an infinite loop (business weekdays are filtered to integers 0–6, and if nothing valid remains, Monday–Friday is used).

### 4-4. No bundled transit-time table

A table of parcel transit days is **deliberately not bundled**. As far as my research went during development, the major Japanese carriers only offer transit times through a search form, and I found no official machine-readable table. Rather than ship guessed values labelled "estimate", the engine **says the data is missing and asks the shop to enter its own observed transit days**.

---

## 5. Keeping order data inside the browser

Everything above runs **in the browser**. An order CSV contains buyers' names and addresses, so saying "we don't upload it" is not enough; it has to be **impossible by construction**.

### 5-1. Detecting the CSV encoding: CP932 "decodes" almost anything

Rakuten exports order CSVs in Shift_JIS, but re-saving in Excel can turn them into UTF-8. The catch is that **CP932 can "successfully" decode almost any byte sequence**. If a UTF-8 CSV contains one broken byte, a strict UTF-8 decode fails while a Shift_JIS decode "succeeds" — the whole file turns to mojibake and **the header-based PII stripping silently misses every column**.

So the engine compares how many multibyte characters decoded cleanly as UTF-8 against how many bytes were broken:

```ts
// apps/web/src/lib/csv/decode.ts (excerpt)
function utf8Verdict(bytes: Uint8Array): CsvEncoding | undefined {
  const text = new TextDecoder('utf-8').decode(bytes.subarray(0, VERDICT_BYTES))
  let decoded = 0
  let broken = 0
  for (let i = 0; i < text.length; i += 1) {
    const code = text.charCodeAt(i)
    if (code === 0xfffd) broken += 1
    else if (code > 0x7f) decoded += 1
  }
  if (decoded > broken * 3) return 'utf-8'
  return broken > 0 ? 'shift_jis' : undefined
}
```

The 3× margin is for **half-width katakana**: Shift_JIS half-width kana bytes (0xA1–0xDF) overlap the byte ranges used by UTF-8 two-byte sequences, so they tend to count on the "decoded as UTF-8" side. Only the first 1 MB is examined; per a comment in the source, decoding the full 20 MB limit used to block the main thread for 70 ms, and this brought it to 4 ms.

After decoding with a candidate encoding, **if the header row contains even one replacement character (U+FFFD)**, that encoding is rejected, because unreadable column names mean the PII columns cannot be identified. If no candidate yields a readable header, the audit stops and explains how to re-export the file.

### 5-2. PII columns are dropped before checking

From the decoded table, **columns such as names, addresses, phone numbers and emails are removed by header pattern before any checking**, and the removed column names are shown to the user. The destination prefecture is needed for delivery math, so only a prefecture code is extracted from the address column; the address string itself appears nowhere in the output.

The hard part is **not catching the personalization text columns**. 名入れ ふりがな (engraving reading) is what we check; 注文者 フリガナ (orderer's name reading) is PII.

```ts
// apps/web/src/lib/csv/pii.ts (excerpt)
/** Never part of engraving text and always identifying. Removed even in a personalization context. */
const HARD_PII = /住所|電話|TEL|携帯|ケータイ|メール|mail|連絡先|郵便番号|〒|FAX/i
/** Columns that are the personalization text itself (kept, since they are checked). */
const NAIRE_CONTEXT = /名入れ|刻印|項目|選択肢|オプション/

export function isPiiHeader(header: string): boolean {
  // Fold half-width kana / full-width Latin variants and the " (2)" suffix added to duplicate headers
  const h = header.normalize('NFKC').replace(/(?: \(\d+\))+$/, '')
  if (ALWAYS_PII.some((re) => re.test(h))) return true
  if (NAIRE_CONTEXT.test(h)) return false
  return PII_HEADER_PATTERNS.some((re) => re.test(h))
}
```

Note that **NFKC is used here**, the opposite of the engraving text. The input is a column header, not something the buyer wrote, and the goal is to absorb half-width/full-width variation. **The same normalization can be right or wrong depending on what it is applied to.**

### 5-3. Enforcing "no upload" with CSP

A line of UI text saying "nothing is uploaded" is not a guarantee. On the page that handles order data (`/audit/`), **the Content-Security-Policy `connect-src` directive does not include the site's own origin (`'self'`)**. Anyone can verify the production header with `curl`:

```bash
curl -sI https://tsukurumae.com/audit/ | grep -i content-security-policy
# connect-src https://*.google-analytics.com https://*.analytics.google.com https://www.googletagmanager.com https://*.clarity.ms
```

Any attempt to `fetch` or `XMLHttpRequest` data back to the operator's server is blocked by the browser. The header is attached at the edge by a CloudFront Response Headers Policy, so it does not depend on the static files being served. For CSP design in general, see [the guide to security headers and CSP in Next.js](/blog/nextjs-security-headers-csp-nonce-middleware-guide).

There is something that **has to be said honestly**. As the header shows, `/audit/` loads analytics (GA4 and Microsoft Clarity). CSP guarantees **no data goes to the operator's server**; it cannot stop the vendors' scripts from reading the page. Elements that display order data therefore carry masking attributes, and a test pins those attributes in place. On the free tools (`/tools/*`), which lay engraving text out one character per table cell, the Clarity script is not allowed to load at all.

### 5-4. Moving heavy checks to a Web Worker

The CSV limit is 20 MB. Checking thousands of orders synchronously freezes the main thread and hurts INP, the Core Web Vitals responsiveness metric, so **above a threshold the check runs in a Web Worker** (for INP in general, see [the Core Web Vitals optimization guide](/blog/core-web-vitals-nextjs-inp-lcp-cls-optimization-guide)).

The threshold was measured, not guessed. Per the source comments, just the structured clone needed to `postMessage` the name dictionary (about 1.2 MB) took 8.6 ms — about the same as checking 200 orders (7.0 ms). So **fewer than 200 orders are checked on the main thread**. When a shop has registered its own fonts, their cmaps are cloned too, so the threshold rises by 35 orders per font.

The worker script (`/_next/static/*`) is served with `connect-src 'none'`. A worker follows **the CSP of its own response, not its parent page's**, so tightening only the page's CSP would not stop a worker from making requests.

```text
# Response header for static files under /_next/static/* (including the worker)
content-security-policy: … script-src 'self' 'unsafe-inline'; connect-src 'none'; …
```

### 5-5. License keys verified offline, too

License keys for shops using the paid settings features are also verified **in the browser without calling a server**. The app holds only a public key and checks an ECDSA P-256 signature with WebCrypto's `crypto.subtle.verify`.

```ts
// apps/web/src/lib/license/verify.ts (excerpt)
const ECDSA_PARAMS: EcdsaParams = { name: 'ECDSA', hash: 'SHA-256' }
const IMPORT_PARAMS: EcKeyImportParams = { name: 'ECDSA', namedCurve: 'P-256' }

async function verifySignature(subtle: SubtleCrypto, key: CryptoKey, parsed: ParsedKey): Promise<boolean> {
  try {
    return await subtle.verify(ECDSA_PARAMS, key, parsed.signature, parsed.signingInput)
  } catch {
    // Some implementations throw on a malformed signature. For our purposes that is just "does not match".
    return false
  }
}
```

Three implementation notes:

- A WebCrypto ECDSA signature is **r and s concatenated, not DER** (the spec converts r and s to fixed-length byte sequences and appends them). For P-256 it is always 64 bytes. A DER signature produced by OpenSSL will not verify as-is.
- `crypto.subtle` exists **only in secure contexts** (HTTPS and the like; it is marked `[SecureContext]`). Served over plain http on an office LAN it is `undefined`, which is handled as its own case rather than "invalid key".
- What gets verified is **the received `tkm1.<payload>` bytes exactly as they arrived**, not a string rebuilt from parsed JSON. That keeps canonicalization problems — key order, whitespace — out of the picture.

As a comment in the code says, a check that runs on the user's device can be modified. **This is not copy protection; it signals the terms of the contract.** For that reason there is no obfuscation.

---

## 6. Decision criteria for your own service

To close, here is a checklist for building similar checks into your own product.

| Area | What to confirm |
| --- | --- |
| **Font** | Is the cmap built from **the exact font used for production or printing**, not a web font used for display? |
| **Glyph ID 0** | Are code points mapped to `.notdef` treated as missing? |
| **Input validation** | Are counts, offsets and ordering in user-supplied fonts verified before reading? Are failures returned as types instead of crashing the page? |
| **Normalization** | Do checks run on the NFC form, with a separate warning when normalization changed the text? Is NFKC kept away from the text itself? |
| **Units** | Characters in graphemes, width from advances, iteration by code point — are the three units kept apart? |
| **Holidays** | Is primary data (the Cabinet Office CSV, for Japan) imported, with results beyond its coverage marked as estimates? |
| **Severity** | Does only what is mechanically certain block? Is anything speculative blocking? |
| **Personal data** | Is "we don't upload it" enforced by something **verifiable from outside**, like CSP `connect-src`? |

Keeping the engine as pure functions without I/O means the same code runs in the browser and in Node, and tests are just lists of inputs and expected outputs. Validating external values at the boundary and making invalid states unrepresentable inside is covered in more depth in [the guide to TypeScript type-safety discipline](/blog/typescript-type-safety-discipline-zod-nevererror-no-any).

---

## 7. The app this code runs in

The code in this article powers [Tsukurumae, a checker for personalized-goods orders](https://tsukurumae.com/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_glyph_check) that tells Japanese shops, entirely inside the browser, whether an engraving, embroidery or print order can be produced and whether it can arrive on time. All checks are deterministic rules; no LLM is involved. Its interface is Japanese and it is built for Japanese marketplaces.

Available for free today:

- **In-browser CSV audit**: loads a Rakuten Pay order CSV, strips personal-data columns, then checks every order for producibility and arrival date ([audit a Rakuten order CSV in the browser](https://tsukurumae.com/audit/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_glyph_check))
- **Three free tools**: a Hepburn romanization checker, a character checker that inspects vendor characters and glyph coverage one character at a time, and a delivery-date estimator

The background and design thinking are written up on [the Tsukurumae project page](/labs/tsukurumae).

If you need input validation that involves fonts and character encodings — completed inside the user's browser, without ever holding their data — I take on that kind of work from design through implementation and testing. See [services](/services) for details.

---

## Where the numbers come from

Every number in this article can be recounted as follows (run from the root of the Tsukurumae repository).

| Number | How to count it |
| --- | --- |
| 10 rules | Length of `RULE_IDS` in `packages/verify/src/lib/rule-ids.ts`. `ls packages/verify/src/rules` lists 12 files; `index.ts` (registration) and `scope.ts` (shared types and helpers) are not rules |
| 578-line font parser | `wc -l packages/verify/src/lib/font.ts` |
| Noto Sans JP: 16,732 code points, 5,246 ranges, 114,695 bytes, unitsPerEm 1000 | Length of `ranges` in `packages/verify/data/cmap/noto-sans-jp.json`, the sum of the range lengths, and `ls -l` |
| 447 vendor characters | `counts.all` in `packages/verify/data/compat-chars.json` |
| 75 holidays (2024–2027) | Length of `holidays` and the `years` field in `packages/verify/data/holidays-jp.json` |
| 460 / 12 compatibility ideographs | Letters (`\p{L}`) in U+F900–FAFF that do / do not change under `normalize('NFC')` in Node 24.16.0 |
| Font parse times, code points, widths | The tables in the text: median of 21 `parseFontToCmap` runs on fonts bundled with macOS in Node 24.16.0, plus `hasGlyph` / `measureWidthMm` results |
| 371 / 255 fonts (fontkit comparison) | Measurement recorded in the header comment of `packages/verify/src/lib/font.ts` (not re-measured by me) |
| 70 ms → 4 ms, 8.6 ms, 200 orders, 35 orders | Comments and constants in `apps/web/src/lib/csv/decode.ts` and `apps/web/src/features/audit/run-audit-async.ts` (timings not re-measured by me) |
| Golden Week example | Output of `estimateArrival` with the conditions given in the text |
