名入れのタンブラー、刺繍入りのタオル、刻印入りのペン。こうした商品では、購入者が入力した文字をそのまま加工します。ここで困るのが、注文を受けた時点では「作れるかどうか」が分からないことです。
- 加工に使うフォントに、その字が入っていない(「髙」「𠮷」、絵文字など)
- 加工方法が扱えない文字種が入っている(刺繍は英字だけ、など)
- 文字数や行数は上限内なのに、実際に並べると刻印できる幅に収まらない
- 希望の到着日が、製作日数と連休を考えるとそもそも間に合わない
どれも、製作担当が作業を始めてから気づくと、購入者への連絡、キャンセル、★1のレビューにつながります。
この記事では、これを注文データを受け取った時点でブラウザの中だけで判定する方法を解説します。題材は、私が作っている名入れ EC 事業者向けの補助ツール ツクルマエ の判定エンジンです。一番の山場は「フォントにその文字があるかを 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 より優先します。
// 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 以上の番号は壊れた参照なので、これも無い字として扱います。
// 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バイト)が並ぶ単純な形式です。ただし、フォントは利用者が持ち込む、中身の分からないバイト列です。宣言された件数をそのまま信じてループを回すと、壊れたファイルや細工されたファイルで固まります。
// 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 件だけ並んでいます。仕様では、それより後ろのグリフは最後の送り幅を共有すると定められています。すべての漢字が同じ幅の和文フォントは、この仕組みでデータを小さくしています。
// 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つの範囲にするという規則です。
// 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. 引くのは二分探索
範囲は昇順に並んでいるので、判定は二分探索で済みます。グリフの有無と送り幅は、同じ探索で両方わかります。
// 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; のように載っているもの)を定めています。
ツクルマエの判定エンジンでは、字が変わったことを隠さない方針にしています。
- グリフ・文字種・文字数の判定(block)には、NFC 後の文字列を使う
- 正規化の前後で文字が変わったこと自体は、
compat-charルールが生の文字列を見て warn を出す
// 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 の結果を置換の候補として示すだけにしています。
// 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)で数えています。
// 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)になります。店舗の画面には、式の途中経過も含めて表示します。
希望到着日 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日ずれることがあるからです。
// 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 として読めた多バイト文字と、壊れたバイトの数を比べて判定しています。
// 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. 個人情報の列は、判定の前に落とす
復号した表からは、列名のパターンで氏名・住所・電話・メールなどの列を判定の前に取り除き、取り除いた列名を利用者に表示します。届け先の都道府県だけは納期の計算に必要なので、住所の列から都道府県コードだけを取り出し、住所の文字列そのものは結果のどこにも残しません。
難しいのは、名入れの本文の列を巻き込まないことです。「名入れ ふりがな」は判定の対象ですが、「注文者 フリガナ」は個人情報です。
// 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 で確かめられます。
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 の実装ガイドで詳しく解説しています。
ただし、正直に書いておくべきことがあります。このヘッダを見ると分かるとおり、/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 の最適化ガイドを参照)。
しきい値は勘で決めず、測って決めています。ソースのコメントによると、人名辞書(約 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 からの通信は止まりません。
# Worker を含む静的ファイル(/_next/static/*)のレスポンスヘッダ
content-security-policy: … script-src 'self' 'unsafe-inline'; connect-src 'none'; …
5-5. ライセンスもオフラインで検証する
有償の設定機能を使う店舗向けのライセンスキーも、サーバーに問い合わせずにブラウザで検証します。ECDSA P-256 の署名を WebCrypto の crypto.subtle.verify で確かめるだけで、アプリには公開鍵しか入っていません。
// 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 の型安全を徹底する方法でも詳しく解説しています。
7. このコードが動いているアプリ
この記事のコードは、名入れ・刻印・刺繍の注文で「その文字が加工できるか」「その日に間に合うか」をブラウザの中だけで確かめる補助ツール、名入れ注文チェックツール「ツクルマエ」で動いています。判定はすべて決定論のルールで行い、LLM は使っていません。
現在、次の機能を無料で公開しています。
- ブラウザ内 CSV 監査:楽天ペイの受注 CSV を読み込み、個人情報の列を除いてから、全注文の加工可否と到着予測を判定します(楽天の受注 CSV をブラウザ内で監査する)
- 無料ツール3つ:ヘボン式チェッカー、機種依存文字とグリフの有無を1文字ずつ確かめる文字チェッカー、お届け目安の計算
開発の経緯や設計の考え方は、ツクルマエの紹介ページにまとめています。
「フォントや文字コードが絡む入力チェックを、利用者のデータを預からずにブラウザの中で完結させたい」といった、個人情報の扱いと判定ロジックの両方に気を配る必要がある機能は、設計から実装・テストまでまとめてお引き受けしています。詳しくはサービス内容をご覧ください。
数値の出典
この記事で使った数値は、すべて次の方法で数え直せます(ツクルマエのリポジトリのルートで実行)。
| 数値 | 数え方 |
|---|---|
| ルールは 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 に本文の条件を入れて実行した結果 |