# フォントの対応文字をJavaScriptで判定する：cmapでグリフの有無と描画幅を調べ、異体字・機種依存文字・祝日込みの納期まで注文時点で検出する

> フォントにその文字があるかを JavaScript で判定する方法。document.fonts.check では判定できない理由、cmap と hmtx を読んでグリフの有無と描画幅を出す実装、NFC で化ける互換漢字・IVS・機種依存文字、祝日込みの納期計算までを名入れ注文チェックの実コードで解説します。

- 公開日: 2026-09-25
- 著者: 友田 陽大
- タグ: TypeScript, フロントエンド, Unicode, フォント, 型安全, セキュリティ, 個人開発
- URL: https://tomodahinata.com/blog/font-glyph-coverage-check-javascript-cmap-guide
- カテゴリ: フロントエンド
- 総合ガイド: https://tomodahinata.com/blog/nextjs-16-app-router-cache-components-data-fetching

## 要点

- 「このフォントにこの文字はあるか」は、フォントファイルの cmap テーブルを読まないと判定できない。`document.fonts.check()` は仕様上、代替フォントで描画される場合も true を返すので、グリフの有無の判定には使えない。
- cmap（format 4 / 12）と hmtx を読めば、グリフの有無と送り幅（描画幅）が両方わかる。連続する符号点のうち送り幅が同じものを範囲にまとめると、Noto Sans JP の 16,732 符号点は 5,246 範囲に収まり、二分探索で引ける。
- NFC 正規化は無害ではない。CJK 互換漢字（例: U+FA19 の「神」）は NFC で U+795E の「神」に置き換わり、字形の指定が消える。検品では NFC 後の文字でグリフを確かめ、正規化の前後で文字が変わったこと自体は別途警告する。
- 文字数は Intl.Segmenter の書記素で数え、幅は送り幅の合計で測る。半角の「ﾀﾞ」は書記素では 1 文字だが、幅は半角 2 つ分になる。数え方の単位を混ぜないことが大事。
- 納期は祝日を規則から計算せず、内閣府の祝日 CSV を使う。振替休日と「国民の休日」は前後の祝日との関係で決まるので、表で持つほうが間違いが少ない。受注データはブラウザの外へ送らず、それを CSP の connect-src で強制している。

---

名入れのタンブラー、刺繍入りのタオル、刻印入りのペン。こうした商品では、購入者が入力した文字をそのまま加工します。ここで困るのが、**注文を受けた時点では「作れるかどうか」が分からない**ことです。

- 加工に使うフォントに、その字が入っていない（「髙」「𠮷」、絵文字など）
- 加工方法が扱えない文字種が入っている（刺繍は英字だけ、など）
- 文字数や行数は上限内なのに、**実際に並べると刻印できる幅に収まらない**
- 希望の到着日が、製作日数と連休を考えると**そもそも間に合わない**

どれも、製作担当が作業を始めてから気づくと、購入者への連絡、キャンセル、★1のレビューにつながります。

この記事では、これを**注文データを受け取った時点でブラウザの中だけで判定する**方法を解説します。題材は、私が作っている名入れ EC 事業者向けの補助ツール [ツクルマエ](/labs/tsukurumae) の判定エンジンです。一番の山場は「**フォントにその文字があるかを JavaScript で確実に判定する**」ところなので、フォントファイルの cmap テーブルの読み方を中心に、Unicode の正規化、機種依存文字、祝日込みの納期計算まで順に説明します。

> **前提**：コードの抜粋は、ツクルマエのリポジトリ（TypeScript、非公開）から、説明に必要な部分だけを切り出したものです。npm には公開していないので、そのまま `install` はできません。数値の数え方は、記事の最後の「数値の出典」にまとめています。

## 0. 結論：何をどこで判定するか

先に判断の表を示します。

| 知りたいこと | 使うもの | 使ってはいけないもの |
| --- | --- | --- |
| フォントにその文字のグリフがあるか | フォントファイルの **cmap テーブル**（format 4 / 12） | `document.fonts.check()`（代替フォントで描ける場合も true を返す） |
| 刻印したときの幅 | **hmtx テーブルの送り幅**の合計 × 文字サイズ ÷ unitsPerEm | 画面での `measureText`（代替フォントの字が混ざっても分からない） |
| 文字数 | **書記素**（`Intl.Segmenter`） | `string.length`（UTF-16 のコードユニット数） |
| 見た目が同じで符号が違う字 | **NFC** で比べて差分を警告する | NFKC の自動適用（①→1、㈱→(株) と字が変わる） |
| 機種依存文字 | CP932 の**ベンダー拡張区にしかない符号点の表** | 「JIS 第1・第2水準以外」という大まかな判定 |
| 希望日に間に合うか | 営業日の計算 ＋ **内閣府の祝日 CSV** | 祝日を規則から自前で計算すること |

判定エンジンは、I/O も LLM も使わない**純粋な TypeScript の関数の集まり**です。ルールは 10 本あり、重大度は3段階に分けています。

| 重大度 | ルール | 判定すること |
| --- | --- | --- |
| block（機械的に確定できるものだけ） | `glyph-missing` / `method-charset` / `length-exceeded` / `delivery-impossible` | グリフが無い／加工方法が扱えない文字種／文字数・行数・描画幅の超過／希望到着日が最短到着日より前 |
| warn | `hepburn` / `option-mismatch` / `remark-directive` / `spacing-anomaly` / `compat-char` | ヘボン式との違い／選択肢と本文の食い違い／備考欄の指示語／空白や異体字セレクタの混入／機種依存文字・CJK 互換漢字 |
| info | `name-dictionary` | 人名辞書に無い名前 |

block にするのは「フォントに無い」「上限を超えている」のように**機械的に確定できるもの**だけです。「裕子」と「祐子」のような打ち間違いは機械には判定できないので、判定しません。この線引きが、検品ツールの信頼性を決めます。

---

## 1. なぜ document.fonts.check() では判定できないのか

「JavaScript フォント 対応文字 判定」で調べると、よく `document.fonts.check()` が出てきます。しかし、これは**グリフの有無を調べる API ではありません**。

CSS Font Loading Module の仕様は、`check()` を「指定したフォントで、あとから『フォントの差し替え』を起こさずに安全に描画できるか」を答えるメソッドだと定義しています。そのうえで、直感に反する2つの場合を明記しています。

- フォントが存在しても、**unicode-range がテキストを含まない**場合は **true** を返す（代替フォントで描画され、読み込みは起きないため）
- 指定したフォントが**1つも存在しない**（名前の打ち間違いなど）場合も **true** を返す（同じく代替フォントで描画されるため）

つまり、グリフが無い字は代替フォントで表示されるだけなので、**`check()` は true になります**。

Canvas の `measureText()` で、対象フォントと代替フォントの幅を比べる方法もあります。しかし、日本語の漢字はどのフォントでもほぼ全角（1em）なので、幅が同じになって区別できないことがよくあります。さらに根本的な問題として、**加工機が使うフォントがブラウザに入っているとは限りません**。ほしいのは「画面にどう表示されるか」ではなく、「**加工に使うそのフォントで作れるか**」です。

そのため、判定は**フォントファイルそのものを読んで**行います。

---

## 2. cmap と hmtx だけを読む：グリフの有無と送り幅

### 2-1. 読むのは2つのテーブルだけ

OpenType（TrueType を含む）のフォントファイルは、テーブルの集まりです。グリフの有無と幅を知るのに必要なのは、次のものだけです。

| テーブル | 中身 | ここで使う値 |
| --- | --- | --- |
| `cmap` | 文字コード → グリフ番号の対応表 | その符号点にグリフがあるか |
| `hmtx` | グリフごとの送り幅（advance width） | 描画幅 |
| `hhea` | 横組みのヘッダ | `numberOfHMetrics`（hmtx に何件あるか） |
| `head` | フォント全体のヘッダ | `unitsPerEm`（1em を何単位とするか） |
| `maxp` | 上限値 | `numGlyphs`（グリフ番号が有効な範囲） |

アウトライン（`glyf` / `CFF`）や字形の置き換え（`GSUB`）は読みません。これらを解釈する汎用のフォントライブラリは多機能ですが、そのぶん重くなります。ツクルマエでは、この5つのテーブルだけを読むパーサを自分で書きました（`packages/verify/src/lib/font.ts`、578 行）。

### 2-2. cmap のサブテーブルを選ぶ

cmap には、プラットフォームと文字コードの組み合わせごとにサブテーブルが並んでいます。Unicode を指すものだけを候補にし、BMP の外（𠮷 など）も扱える **format 12 を format 4 より優先**します。

```ts
// packages/verify/src/lib/font.ts（抜粋）
/** Unicode を指す encoding だけを候補にする。Macintosh(1) と Windows Symbol(3,0) は別の符号系。 */
function isUnicodeEncoding(platformId: number, encodingId: number): boolean {
  if (platformId === 0) return true
  return platformId === 3 && (encodingId === 1 || encodingId === 10)
}

/** format 12（BMP 外を含む）を format 4 より優先し、同格なら Windows を採る。 */
function selectSubtable(view: DataView, cmap: TableRecord): Subtable {
  // …サブテーブルの一覧を走査…
  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 }
  // …
}
```

format 4 しか持たないフォントでは、BMP の外の字は**必ず「無い」**と判定されます。これは不具合ではなく、フォントの事実どおりの結果です。

### 2-3. グリフ番号 0 は「無い」

ここが見落としやすい点です。cmap は符号点をグリフ番号に対応させますが、**グリフ番号 0 は `.notdef`（「グリフが無い」を表す予約値）**です。cmap に載っていても、0 を指していれば無い字として扱います。`numGlyphs` 以上の番号は壊れた参照なので、これも無い字として扱います。

```ts
// packages/verify/src/lib/font.ts（抜粋）
/**
 * グリフ ID が使えるか。0 は「グリフ無し」を表す予約値、numGlyphs 以上は壊れた参照。
 * どちらも「収録されていない」として扱う（無い文字を有ると判定すると名入れが失敗する）。
 */
function isUsableGlyph(glyphId: number, numGlyphs: number): boolean {
  return glyphId > 0 && glyphId < numGlyphs
}
```

OpenType の仕様では、format 4 の最後のセグメントは必ず `startCode = endCode = 0xFFFF` で終わり、この符号点は通常 missingGlyph（番号 0）を指します。汎用ライブラリの中には、グリフ番号を見ずに符号点を列挙するものがあり、その場合は U+FFFF が「収録されている」ように見えます。コメントによると、macOS に同梱されている 371 本のフォントで fontkit の結果と突き合わせたところ、255 本で差が出たのはこの U+FFFF の1件だけでした。

### 2-4. format 12 を読む：宣言された件数を信じない

format 12 は「開始符号点・終了符号点・開始グリフ番号」の組（12バイト）が並ぶ単純な形式です。ただし、フォントは**利用者が持ち込む、中身の分からないバイト列**です。宣言された件数をそのまま信じてループを回すと、壊れたファイルや細工されたファイルで固まります。

```ts
// packages/verify/src/lib/font.ts（抜粋）
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
  // 件数から領域を確保しない。宣言された件数分の実体があることを先に確かめる。
  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))
    }
  }
}
```

**昇順で重なりが無いこと**を強制しているのには、2つの理由があります。1つは、後で二分探索を使うための前提になること。もう1つは、**1つの符号点が高々1回しか処理されない**ことが保証され、ループの総回数が Unicode の空間（1,114,112）を超えなくなることです。反復回数の上限を別に設けなくても、入力の検証がそのまま上限になります。

読み出しはすべて `need()` で範囲を確かめてから行い、失敗は例外として外に投げず、`{ ok: false, error: { kind, message } }` という型で返します。`kind` は `'woff2'`、`'truncated'`、`'unsupported-cmap'` などに分かれているので、画面側は「WOFF2 には対応していません。TTF / OTF をご用意ください」のように、**次に何をすればよいかが分かる文言**を理由ごとに出し分けられます。

### 2-5. 送り幅：numberOfHMetrics を超えたグリフは最後の値を使う

hmtx には、グリフごとの送り幅が `numberOfHMetrics` 件だけ並んでいます。仕様では、それより後ろのグリフは**最後の送り幅を共有する**と定められています。すべての漢字が同じ幅の和文フォントは、この仕組みでデータを小さくしています。

```ts
// packages/verify/src/lib/font.ts（抜粋）
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. 「符号点 × 送り幅」を範囲にまとめる

読んだ結果は、`[開始符号点, 終了符号点, 送り幅]` の配列にまとめます。**符号点が連続していて、送り幅も同じなら1つの範囲にする**という規則です。

```ts
// packages/verify/src/lib/font.ts（抜粋）
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
}
```

和文フォントは漢字の送り幅がすべて同じなので、この方法でよく縮みます。同梱している Noto Sans JP では、**16,732 符号点が 5,246 範囲**になり、JSON にして 114,695 バイトです。`MAX_RANGES`（100,000）は、符号点ごとに送り幅を変えた細工フォントでメモリを使い切らせないための上限です。

手元の Mac（Node 24.16.0）で、このパーサに実際のフォントを読ませた結果は次のとおりです（21回実行した中央値）。

| フォント | ファイルサイズ | cmap の形式 | 収録符号点 | 範囲の数 | 解析時間 |
| --- | --- | --- | --- | --- | --- |
| Arial Unicode MS | 22.2 MB | format 4 | 38,917 | 3,266 | 0.38 ms |
| ヒラギノ角ゴシック W3（TTC・4本収録の1本目） | 7.5 MB | format 12 | 13,861 | 5,685 | 0.65 ms |

処理時間を決めるのはファイルの大きさではなく、**収録されている符号点の数**です。数十 MB のフォントを読み込んでも、画面は固まりません。

### 2-7. 引くのは二分探索

範囲は昇順に並んでいるので、判定は二分探索で済みます。グリフの有無と送り幅は、同じ探索で両方わかります。

```ts
// packages/verify/src/lib/cmap.ts（抜粋）
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
}

/** 文字列の描画幅（mm）。グリフが無い文字は全角相当（1em）で見積もる。異体字セレクタは 0 幅。 */
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)` は、UTF-16 のコードユニットではなく**符号点**ごとに回ります。`text[i]` や `charCodeAt` で回すと、𠮷（U+20BB7）のようなサロゲートペアが2つに割れ、存在しない符号点を探してしまいます。

### 2-8. 同じ文字列でも、フォントが違えば結果が変わる

同じ入力を、3つのフォントの cmap で判定した実測結果です。

| 入力 | Noto Sans JP | Arial Unicode MS | ヒラギノ角ゴ W3 |
| --- | --- | --- | --- |
| 髙（U+9AD9） | あり | あり | あり |
| 𠮷（U+20BB7） | あり | **無し** | あり |
| ①（U+2460） | あり | あり | あり |
| 🎂（U+1F382） | **無し** | **無し** | **無し** |
| 「Yamada Taro」を 5 mm で組んだ幅 | 30.73 mm | 30.29 mm | 33.01 mm |

ヒラギノ角ゴと Arial Unicode では、同じ英字でも 2.7 mm の差が出ます。刻印できる幅が 32 mm の商品なら、フォントによって結果が「収まる」にも「はみ出す」にもなります。**判定は、加工に使うフォントの cmap で行わなければ意味がありません**。ツクルマエの判定エンジンは、商品ごとの設定（`ProductProfile.font`）でどのフォントの cmap を使うかを指定します。

なお、この幅は**送り幅を単純に足した見積もり**です。カーニング（`GPOS`）やプロポーショナル字形（`palt`）は反映していません。上限ぎりぎりの注文は、加工機側のプレビューで最終確認してもらう前提にしています。

---

## 3. Unicode の落とし穴：正規化・異体字・機種依存文字

グリフの判定を正しく書いても、**判定する前の文字列**が思ったものと違えば、結果も間違います。名入れの注文で実際に問題になるのは、次のような字です。

| 入力 | 符号 | NFC 後 | NFKC 後 | 判定エンジンの扱い |
| --- | --- | --- | --- | --- |
| カ＋結合用濁点 | U+30AB U+3099 | ガ（U+30AC） | ガ | NFC で合成してから判定し、合成前の形だったことを warn |
| 神（CJK 互換漢字） | U+FA19 | **神（U+795E）** | 神（U+795E） | NFC で字が変わったことを warn |
| 﨑 | U+FA11 | 﨑（変わらない） | 﨑 | CP932 のベンダー拡張にしかない字として warn |
| 髙 | U+9AD9 | 変わらない | 変わらない | 機種依存文字として warn、「高」と混同しやすいことも伝える |
| ① | U+2460 | 変わらない | **1** | 機種依存文字として warn、置換候補として「1」を示す |
| ㈱ | U+3231 | 変わらない | **(株)** | 同上 |
| ﾀﾞ（半角） | U+FF80 U+FF9E | 変わらない | ダ | 書記素では 1 文字。幅は半角2つ分 |
| 葛＋IVS | U+845B U+E0100 | 変わらない | 変わらない | セレクタを取り除いて「葛」で判定し、指定が入っていたことを warn |
| 🎂 | U+1F382 | 変わらない | 変わらない | Noto Sans JP に無いので block |

### 3-1. NFC は無害ではない：CJK 互換漢字

「とりあえず NFC にそろえておけば安全」と思われがちですが、NFC でも字は変わります。**CJK 互換漢字**（U+F900〜FAFF、U+2F800〜2FA1F）の多くは、**単独の字への正規分解（singleton）**を持っているからです。UAX #15 も、singleton は NFC でも元に戻らないと説明しています。

U+F900〜FAFF のブロックを Node 24 で数えると、NFC で別の字に変わるものが 460 字、変わらないものが 12 字ありました。変わらない 12 字（U+FA0E、FA0F、FA11 など）は、名前は「互換漢字」でも実際には統合漢字として扱われている字で、「﨑」（U+FA11）もその1つです。

問題は、互換漢字が**字形の違いを残すために**使われていることです。U+FA19 の「神」は、示へんの形が違う字形を指定するために入力されることがあり、NFC で U+795E にそろえると、その指定は消えます。Unicode は、互換漢字と同じ字形を正規化で失わずに表すため、**標準化された異体字シーケンス**（StandardizedVariants.txt に `795E FE00; CJK COMPATIBILITY IDEOGRAPH-FA19;` のように載っているもの）を定めています。

ツクルマエの判定エンジンでは、**字が変わったことを隠さない**方針にしています。

1. グリフ・文字種・文字数の判定（block）には、**NFC 後の文字列**を使う
2. 正規化の前後で文字が変わったこと自体は、`compat-char` ルールが**生の文字列を見て warn** を出す

```ts
// packages/verify/src/lib/text.ts（抜粋）
/**
 * block ルール（グリフ・文字種・文字数）が見る形に正規化する。
 * - NFC（結合濁点などを合成。compat-char は生文字列で警告し続ける）
 * - タブは半角スペースに、その他の制御文字・ゼロ幅文字は除去（spacing-anomaly が warn を出す）
 * - 異体字セレクタは除去（ベース文字で加工される。spacing-anomaly が warn を出す）
 */
export function normalizeForBlockRules(line: string): string {
  return line.normalize('NFC').replace(/\t/g, ' ').replace(CONTROL_CHARS_G, '').replace(VARIATION_SELECTORS_G, '')
}
```

「正規化してから判定する」と「正規化で失われた情報を店舗に伝える」は、**別の処理として両方行う**必要があります。

### 3-2. NFKC を自動で適用しない

NFKC は、①を「1」に、㈱を「(株)」に、全角英字を半角に変えます。検索用のキーを作るには便利ですが、**名入れの本文に適用すると、購入者が指定したものと違う字を加工することになります**。UAX #15 も「NFKC と NFKD を任意のテキストにむやみに適用してはならない」と書いています。

判定エンジンでは、NFKC の結果を**置換の候補**として示すだけにしています。

```ts
// packages/verify/src/lib/compat.ts（抜粋）
/** 置換候補は NFKC で字が変わる場合のみ（機種依存文字・互換漢字向け）。 */
function nfkcHint(char: string): { suggestion?: string } {
  const nfkc = char.normalize('NFKC')
  return nfkc === char ? {} : { suggestion: nfkc }
}
```

### 3-3. 異体字セレクタ（IVS）は幅ゼロで、グリフも無い

「葛」や「辻」の字形を指定するために、**異体字セレクタ**（VS1〜16 の U+FE00〜FE0F、IVS に使う U+E0100〜E01EF）が付いてくることがあります。Windows の IME で異体字の候補を選んだり、Word から貼り付けたりすると混ざります。

セレクタ自体は、直前の字の字形を指定する**幅ゼロの符号**です。単独のグリフは持たないので、そのまま cmap で引くと「グリフが無い」と誤って判定します。そこで、判定エンジンは次のように扱います。

- グリフ・文字種・文字数の判定の**前に取り除く**（ベースの字で加工できるかを見る）
- 描画幅の計算でも**幅ゼロ**として扱う（`measureWidthMm` の `isVariationSelector`）
- 指定が入っていたことは、`spacing-anomaly` ルールが**符号を示して warn** を出す（字形は画面上ほとんど変わらないので、符号を出さないと店舗には違いが見えない）

加工に使うフォントがその IVS に対応しているか（cmap の format 14）までは、現時点では判定していません。これは正直に書いておくべき限界です。

### 3-4. 機種依存文字は「CP932 のベンダー拡張区だけにある字」で判定する

「機種依存文字」はあいまいな言葉です。判定エンジンでは、Unicode が公開している **CP932.TXT**（Windows のコードページ 932 の対応表）から、**NEC 特殊文字（13区）、NEC 選定 IBM 拡張（89〜92区）、IBM 拡張（115〜119区）にしかない符号点**を抜き出して表にしています。JIS X 0208 の標準の区と重なる字は除いているので、対象は **447 字**です。①、Ⅱ、㈱、髙、﨑などがここに入ります。

受注 CSV を Shift_JIS として読む場合、ブラウザの `TextDecoder('shift_jis')` は WHATWG Encoding Standard に従い、これらのベンダー拡張も読めます。実際に 0x87 0x40 は「①」（U+2460）、0xFB 0xFC は「髙」（U+9AD9）に、0x81 0x60 は全角チルダ「～」（U+FF5E）に復号されます。**CSV の文字化けは起きなくても、加工機やフォントがその字を扱えるかは別の問題**です。

### 3-5. 文字数は書記素で数え、幅は符号点で測る

「10文字まで」という上限を `string.length` で判定すると、𠮷は 2 文字、👨‍👩‍👧 は 8 文字と数えられてしまいます。判定エンジンでは、`Intl.Segmenter` の**書記素**（利用者が「1文字」と感じる単位。UAX #29）で数えています。

```ts
// packages/verify/src/lib/codepoints.ts（抜粋）
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
}
```

ここで注意したいのが、**半角の濁点**です。半角の「ﾀﾞ」（U+FF80 U+FF9E）は、書記素では **1 文字**と数えられます（U+FF9E が直前の字に結合する扱いのため。実測で `countChars('ﾔﾏﾀﾞ')` は 3）。しかし描画幅は、半角の字 2 つ分です。文字数の上限は書記素で、描画幅の上限は符号点ごとの送り幅で判定し、**2つの単位を混ぜない**ようにしています。

---

## 4. 祝日込みの納期：規則で計算せず、表で持つ

### 4-1. 最短到着日の計算

「間に合わない注文」の判定は、次の式で行います。

- **受付日**：締め時刻を過ぎた注文は翌日。受付日が休業日なら、次の営業日にずらす
- **出荷可能日**：受付日 ＋ 製作日数（営業日）
- **最短到着日**：出荷可能日 ＋ 配送日数（暦日。宅配便は土日祝も配達する前提）
- **希望到着日が最短到着日より前**なら block

2026年のゴールデンウィーク直前に、判定エンジンの `estimateArrival` を実際に動かした結果です。条件は、12時締め、製作 3 営業日、配送 2 日、注文は 4月28日（火）13時30分、希望到着日は 5月5日です。

| ステップ | 日付 | 理由 |
| --- | --- | --- |
| 受付日 | 4/30（木） | 12時を過ぎたので 4/29 に回るが、4/29 は昭和の日なので次の営業日へ |
| 製作 1 営業日目 | 5/1（金） | |
| 製作 2 営業日目 | 5/7（木） | 5/2〜5/6 は土日と祝日・休日 |
| 出荷可能日 | 5/8（金） | 製作 3 営業日目 |
| 最短到着日 | 5/10（日） | 配送 2 日 |

希望は 5/5 なので、この注文は `delivery-impossible`（block）になります。店舗の画面には、式の途中経過も含めて表示します。

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

**途中の日付を全部見せる**のは、店舗が自分で検算できるようにするためです。「間に合いません」とだけ表示すると、店舗は判定を信じるか無視するかしか選べません。

### 4-2. 祝日を規則から計算しない理由

5/6 が休みなのは、5/3（憲法記念日）が日曜日にあたったための**振替休日**です。祝日法は「国民の祝日が日曜日に当たるときは、その日後においてその日に最も近い『国民の祝日』でない日を休日とする」と定めています。さらに、2026年 9月22日は、前日（敬老の日）と翌日（秋分の日）がどちらも祝日なので、「**国民の休日**」になります。

そして、春分の日と秋分の日は、**法律に日付が書かれていません**。条文は「春分日」「秋分日」とだけ定めていて、実際の日付は天文学的な計算で決まります。

これを自分で実装するより、**内閣府が公開している祝日 CSV**（`syukujitsu.csv`）を取り込むほうが確実です。判定エンジンには 2024〜2027 年の 75 件を同梱し、**収録範囲の外の年にかかる計算では、結果に「祝日データが期間を網羅していないため目安」と明記**します。範囲外を黙って平日として数えると、網羅しているかのように見えてしまうからです。

### 4-3. 日付は Date を使わずに整数で計算する

日付の計算には `Date` を使わず、**1970-01-01 からの通算日数**（Howard Hinnant の `days_from_civil`）を整数で扱っています。`new Date('2026-05-06')` は UTC として解釈され、`getDate()` は実行環境のタイムゾーンで返ります。そのため、同じコードでもサーバー（UTC）と利用者のブラウザ（JST）で日付が1日ずれることがあるからです。

```ts
// packages/verify/src/lib/date.ts（抜粋）
/** 起点（営業日に丸めた日）から n 営業日後。n=0 なら起点そのもの。 */
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` には、ループを最大 366 回で打ち切る上限を入れています。祝日リストが壊れていたり、営業曜日に `[7]` のような範囲外の値が入っていたりしても、無限ループにはなりません（営業曜日の値は 0〜6 の整数だけを残し、有効な値が無ければ月〜金に戻します）。

### 4-4. 配送日数の表は同梱しない

宅配便の配送日数の表は、**あえて同梱していません**。開発時に調べた範囲では、大手の運送会社はいずれも配送日数を検索フォームでしか提供しておらず、機械で読める公式の表が見つかりませんでした。推測した値に「目安」と書いて出すより、**入っていないことを明示し、店舗に実績の日数を入力してもらう**ほうが誠実だと判断しました。

---

## 5. 受注データをブラウザの外に出さない

ここまでの判定は、すべて**ブラウザの中で**動きます。受注 CSV には購入者の氏名や住所が入っているので、「サーバーへ送らない」と書くだけでなく、**仕組みとして送れないようにする**必要があります。

### 5-1. CSV の文字コードを判別する：CP932 は何でも「読めてしまう」

楽天の受注 CSV は Shift_JIS で出力されます。しかし、Excel で保存し直すと UTF-8 になることもあります。ここで厄介なのは、**CP932 はほとんどどんなバイト列も「正しく」復号できてしまう**ことです。UTF-8 の CSV に壊れたバイトが1つ混ざると、UTF-8 の厳密な復号は失敗し、Shift_JIS の復号は「成功」します。その結果、全体が文字化けし、**列名で判定している個人情報の除去が丸ごと空振り**します。

そこで、UTF-8 として読めた多バイト文字と、壊れたバイトの数を比べて判定しています。

```ts
// apps/web/src/lib/csv/decode.ts（抜粋）
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
}
```

3倍の余裕を取っているのは、**半角カナ**への対策です。Shift_JIS の半角カナのバイト（0xA1〜0xDF）は、UTF-8 の2バイト文字の一部と同じ値の範囲にあるため、「UTF-8 として読めた」側に数えられやすくなります。判定に使うのは先頭の 1 MB だけです。コメントによると、上限の 20 MB を丸ごと復号したときにメインスレッドが 70 ms 止まっていたものが、4 ms になりました。

候補の文字コードで復号したあと、**ヘッダ行に置換文字（U+FFFD）が1つでもあれば**、その文字コードは不採用にします。列名が読めなければ個人情報の列を特定できないからです。候補をすべて試してもヘッダが読めなければ、判定を中止して、保存し直す手順を案内します。

### 5-2. 個人情報の列は、判定の前に落とす

復号した表からは、列名のパターンで**氏名・住所・電話・メールなどの列を判定の前に取り除き**、取り除いた列名を利用者に表示します。届け先の都道府県だけは納期の計算に必要なので、住所の列から都道府県コードだけを取り出し、住所の文字列そのものは結果のどこにも残しません。

難しいのは、**名入れの本文の列を巻き込まない**ことです。「名入れ ふりがな」は判定の対象ですが、「注文者 フリガナ」は個人情報です。

```ts
// apps/web/src/lib/csv/pii.ts（抜粋）
/** 名入れ本文にはあり得ず、常に本人を特定する情報。名入れ文脈でも除去する。 */
const HARD_PII = /住所|電話|TEL|携帯|ケータイ|メール|mail|連絡先|郵便番号|〒|FAX/i
/** 名入れ本文そのものを指す列（判定対象なので残す）。 */
const NAIRE_CONTEXT = /名入れ|刻印|項目|選択肢|オプション/

export function isPiiHeader(header: string): boolean {
  // 半角カナ・全角英字の揺れと、同名の列に付く「 (2)」を畳む
  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))
}
```

ここでは、本文とは逆に **NFKC を使っています**。対象が購入者の書いた本文ではなく列名で、半角カナや全角英字の表記ゆれを吸収したいからです。**同じ正規化でも、対象によって適切かどうかが変わる**という良い例です。

### 5-3. 「送らない」を CSP で強制する

「送信しません」と画面に書くだけでは約束になりません。ツクルマエでは、受注データを扱う画面（`/audit/`）の **Content-Security-Policy の `connect-src` に、自分のオリジン（`'self'`）を含めていません**。本番のレスポンスヘッダは、誰でも `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
```

`fetch` や `XMLHttpRequest` で運営者のサーバーへ送ろうとしても、ブラウザが遮断します。ヘッダは CloudFront の Response Headers Policy でエッジから付けているので、配信する静的ファイルの中身に左右されません。CSP の組み立て方全般は、[Next.js のセキュリティヘッダと CSP の実装ガイド](/blog/nextjs-security-headers-csp-nonce-middleware-guide)で詳しく解説しています。

ただし、**正直に書いておくべきこと**があります。このヘッダを見ると分かるとおり、`/audit/` にはアクセス解析（GA4 と Microsoft Clarity）を載せています。CSP で保証できるのは「**運営者のサーバーへは送らない**」ところまでで、解析ベンダーのスクリプトがページの内容を読めることまでは止められません。そのため、受注データを表示する要素にはマスキングの属性を付け、その属性が付いていることをテストで固定しています。一方、刻印の本文を1文字ずつ表に並べる無料ツール（`/tools/*`）では、Clarity のスクリプト自体を CSP で読み込ませないようにしています。

### 5-4. 重い判定は Web Worker に逃がす

CSV は 20 MB まで受け付けます。数千件の注文を同期で判定するとメインスレッドが固まり、INP（Core Web Vitals の応答性の指標）が悪化します。そのため、**一定の件数を超えたら Web Worker で判定**します（INP の改善方法は [Core Web Vitals の最適化ガイド](/blog/core-web-vitals-nextjs-inp-lcp-cls-optimization-guide)を参照）。

しきい値は勘で決めず、測って決めています。ソースのコメントによると、人名辞書（約 1.2 MB）を `postMessage` で渡すための構造化複製だけで 8.6 ms かかり、これは 200 件を判定する時間（7.0 ms）とほぼ同じでした。そのため、**200 件未満はメインスレッドで判定**します。店舗のフォントを登録している場合は、その cmap も複製するので、1本あたり 35 件ぶんしきい値を上げています。

Worker のスクリプト（`/_next/static/*`）には、`connect-src 'none'` の CSP を付けています。Worker は親ページではなく**自分のレスポンスの CSP に従う**ので、画面側の CSP を絞っただけでは Worker からの通信は止まりません。

```text
# Worker を含む静的ファイル（/_next/static/*）のレスポンスヘッダ
content-security-policy: … script-src 'self' 'unsafe-inline'; connect-src 'none'; …
```

### 5-5. ライセンスもオフラインで検証する

有償の設定機能を使う店舗向けのライセンスキーも、**サーバーに問い合わせずに**ブラウザで検証します。ECDSA P-256 の署名を WebCrypto の `crypto.subtle.verify` で確かめるだけで、アプリには公開鍵しか入っていません。

```ts
// apps/web/src/lib/license/verify.ts（抜粋）
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 {
    // 実装によっては壊れた署名で例外になる。判定としては「合わない」と同じ。
    return false
  }
}
```

実装上の注意点は3つあります。

- WebCrypto の ECDSA の署名は、**DER 形式ではなく r と s を連結したもの**です（仕様で r と s をそれぞれ固定長のバイト列にして連結すると定めています）。P-256 なら常に 64 バイトです。OpenSSL などで作った DER の署名は、そのままでは検証できません。
- `crypto.subtle` は **安全なコンテキスト（HTTPS など）でしか使えません**（`[SecureContext]`）。社内 LAN に http で配信すると `undefined` になるので、「キーが不正」とは別の理由として扱います。
- 署名の対象は、JSON を読み直して作り直した文字列ではなく、**受け取った `tkm1.<ペイロード>` のバイト列そのもの**にしています。キーの並び順や空白の違いで検証結果が変わる、正準化の問題を持ち込まないためです。

コードのコメントにもあるとおり、利用者の端末で動く判定は書き換えられます。**これはコピー防止ではなく、契約の範囲を示すための仕組み**です。そのため、難読化はしていません。

---

## 6. 設計の判断基準：自分のサービスに当てはめるとき

最後に、似た判定を自分のサービスに組み込むときのチェックリストをまとめます。

| 観点 | 確認すること |
| --- | --- |
| **フォント** | 判定に使う cmap は、**加工・印刷に使うフォントそのもの**から作っているか。画面表示用の Web フォントで代用していないか |
| **グリフ番号 0** | `.notdef` を指す符号点を「無い」として扱っているか |
| **入力の検証** | 持ち込まれるフォントの件数・位置・順序を、読む前に確かめているか。失敗を型で返し、画面を落とさないか |
| **正規化** | 判定には NFC 後の文字列を使い、正規化で字が変わったことは別途警告しているか。NFKC を本文に自動で適用していないか |
| **単位** | 文字数は書記素、幅は送り幅、ループは符号点。3つの単位が混ざっていないか |
| **祝日** | 一次データ（内閣府 CSV）を取り込み、収録範囲の外では「目安」と明示しているか |
| **重大度** | block にするのは機械的に確定できるものだけか。推測を block にしていないか |
| **個人情報** | 「送らない」を CSP の `connect-src` のように、**外から検証できる仕組み**で強制しているか |

判定エンジンを I/O の無い純粋な関数にしておくと、同じコードをブラウザでも Node でも動かせ、テストも「入力と期待する結果」を並べるだけで書けます。外部から来る値を境界で検証し、内部では型で不正な状態を表せないようにする考え方は、[TypeScript の型安全を徹底する方法](/blog/typescript-type-safety-discipline-zod-nevererror-no-any)でも詳しく解説しています。

---

## 7. このコードが動いているアプリ

この記事のコードは、名入れ・刻印・刺繍の注文で「その文字が加工できるか」「その日に間に合うか」をブラウザの中だけで確かめる補助ツール、[名入れ注文チェックツール「ツクルマエ」](https://tsukurumae.com/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_glyph_check)で動いています。判定はすべて決定論のルールで行い、LLM は使っていません。

現在、次の機能を無料で公開しています。

- **ブラウザ内 CSV 監査**：楽天ペイの受注 CSV を読み込み、個人情報の列を除いてから、全注文の加工可否と到着予測を判定します（[楽天の受注 CSV をブラウザ内で監査する](https://tsukurumae.com/audit/?utm_source=tomodahinata.com&utm_medium=referral&utm_campaign=blog_glyph_check)）
- **無料ツール3つ**：ヘボン式チェッカー、機種依存文字とグリフの有無を1文字ずつ確かめる文字チェッカー、お届け目安の計算

開発の経緯や設計の考え方は、[ツクルマエの紹介ページ](/labs/tsukurumae)にまとめています。

「フォントや文字コードが絡む入力チェックを、利用者のデータを預からずにブラウザの中で完結させたい」といった、個人情報の扱いと判定ロジックの両方に気を配る必要がある機能は、設計から実装・テストまでまとめてお引き受けしています。詳しくは[サービス内容](/services)をご覧ください。

---

## 数値の出典

この記事で使った数値は、すべて次の方法で数え直せます（ツクルマエのリポジトリのルートで実行）。

| 数値 | 数え方 |
| --- | --- |
| ルールは 10 本 | `packages/verify/src/lib/rule-ids.ts` の `RULE_IDS` の要素数。`ls packages/verify/src/rules` は 12 ファイルで、そのうち `index.ts`（登録）と `scope.ts`（共通の型と補助関数）はルールではない |
| フォントパーサは 578 行 | `wc -l packages/verify/src/lib/font.ts` |
| Noto Sans JP：16,732 符号点、5,246 範囲、114,695 バイト、unitsPerEm 1000 | `packages/verify/data/cmap/noto-sans-jp.json` の `ranges` の要素数と、各範囲の長さの合計、`ls -l` |
| 機種依存文字 447 字 | `packages/verify/data/compat-chars.json` の `counts.all` |
| 祝日 75 件（2024〜2027年） | `packages/verify/data/holidays-jp.json` の `holidays` の要素数と `years` |
| 互換漢字 460 字／12 字 | Node 24.16.0 で U+F900〜FAFF の文字（`\p{L}`）のうち `normalize('NFC')` で変わるものと変わらないもの |
| フォントの解析時間・符号点数・幅 | 本文の表。Node 24.16.0、macOS 同梱フォントを `parseFontToCmap` に 21 回読ませた中央値と、`hasGlyph` / `measureWidthMm` の結果 |
| 371 本・255 本（fontkit との突き合わせ） | `packages/verify/src/lib/font.ts` 冒頭のコメントに記録された実測（私は再計測していない） |
| 70 ms → 4 ms、8.6 ms、200 件、35 件 | `apps/web/src/lib/csv/decode.ts` と `apps/web/src/features/audit/run-audit-async.ts` のコメントと定数（時間は私は再計測していない） |
| GW の納期の例 | `estimateArrival` に本文の条件を入れて実行した結果 |
