Skip to main content
Frontend
TypeScript
Frontend
Unicode
Fonts
Type Safety
Security
Indie Development

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
Reading time
30 min read
Author
友田 陽大
Share

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, 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 knowUseDo not use
Whether the font has a glyph for the characterThe font file's cmap table (format 4 / 12)document.fonts.check() (returns true when a fallback font will draw it)
Width once engravedSum of hmtx advance widths × font size ÷ unitsPerEmOn-screen measureText (fallback glyphs sneak in unnoticed)
Character countGrapheme clusters (Intl.Segmenter)string.length (UTF-16 code units)
Characters that look identical but differ in code pointCompare under NFC and warn about differencesAutomatic NFKC (① becomes 1, ㈱ becomes (株))
Vendor-specific ("platform-dependent") charactersA table of code points that exist only in CP932's vendor extension rowsA vague "anything outside JIS levels 1 and 2" rule
Whether the requested date is achievableBusiness-day arithmetic + the Cabinet Office holiday CSVDeriving 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.

SeverityRulesWhat they detect
block (only what can be decided mechanically)glyph-missing / method-charset / length-exceeded / delivery-impossibleNo glyph / a script the process cannot produce / too many characters, lines, or too wide / requested date before the earliest possible arrival
warnhepburn / option-mismatch / remark-directive / spacing-anomaly / compat-charDeviations 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
infoname-dictionaryA 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.


I can take on the implementation from this article as an engagement

React / Next.js front-end implementation, through to accessibility

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:

TableContentsValue used here
cmapCharacter code → glyph ID mappingWhether the code point has a glyph
hmtxPer-glyph advance widthRendered width
hheaHorizontal headernumberOfHMetrics (how many entries hmtx has)
headFont headerunitsPerEm (units per em)
maxpMaximum profilenumGlyphs (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.

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

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

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

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

// 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):

FontFile sizecmap formatCode pointsRangesParse time
Arial Unicode MS22.2 MBformat 438,9173,2660.38 ms
Hiragino Sans W3 (first of 4 fonts in the TTC)7.5 MBformat 1213,8615,6850.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.

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

// 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:

InputNoto Sans JPArial Unicode MSHiragino Sans W3
髙 (U+9AD9)yesyesyes
𠮷 (U+20BB7)yesnoyes
① (U+2460)yesyesyes
🎂 (U+1F382)nonono
Width of "Yamada Taro" set at 5 mm30.73 mm30.29 mm33.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:

InputCode pointsAfter NFCAfter NFKCHow the engine treats it
カ + combining voiced markU+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+FA11unchanged﨑Warns: exists only in CP932's vendor extensions
髙U+9AD9unchangedunchangedWarns as a vendor character, and that it is easily confused with 高
①U+2460unchanged1Warns as a vendor character; offers "1" as a suggestion
㈱U+3231unchanged(株)Same as above
ダ (half-width)U+FF80 U+FF9EunchangedダOne grapheme, but two half-width advances wide
葛 + IVSU+845B U+E0100unchangedunchangedSelector stripped, checked as 葛; warns that a variant was requested
🎂U+1F382unchangedunchangedNot 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.
// 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:

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

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

StepDateWhy
AcceptanceThu 4/30Past noon, so it rolls to 4/29 — Shōwa Day, a holiday — and then to the next business day
Production day 1Fri 5/1
Production day 2Thu 5/75/2–5/6 are the weekend plus holidays
Ship dateFri 5/8Production day 3
Earliest arrivalSun 5/102 days in transit

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

希望到着日 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.

// 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:

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

// 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:

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.

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

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.

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

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

AreaWhat to confirm
FontIs the cmap built from the exact font used for production or printing, not a web font used for display?
Glyph ID 0Are code points mapped to .notdef treated as missing?
Input validationAre counts, offsets and ordering in user-supplied fonts verified before reading? Are failures returned as types instead of crashing the page?
NormalizationDo checks run on the NFC form, with a separate warning when normalization changed the text? Is NFKC kept away from the text itself?
UnitsCharacters in graphemes, width from advances, iteration by code point — are the three units kept apart?
HolidaysIs primary data (the Cabinet Office CSV, for Japan) imported, with results beyond its coverage marked as estimates?
SeverityDoes only what is mechanically certain block? Is anything speculative blocking?
Personal dataIs "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.


7. The app this code runs in

The code in this article powers Tsukurumae, a checker for personalized-goods orders 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)
  • 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.

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

NumberHow to count it
10 rulesLength 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 parserwc -l packages/verify/src/lib/font.ts
Noto Sans JP: 16,732 code points, 5,246 ranges, 114,695 bytes, unitsPerEm 1000Length of ranges in packages/verify/data/cmap/noto-sans-jp.json, the sum of the range lengths, and ls -l
447 vendor characterscounts.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 ideographsLetters (\p{L}) in U+F900–FAFF that do / do not change under normalize('NFC') in Node 24.16.0
Font parse times, code points, widthsThe 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 ordersComments 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 exampleOutput of estimateArrival with the conditions given in the text

Frequently asked questions

What is the most reliable way to check in JavaScript whether a font contains a character?
Read the font file (TTF, OTF or TTC) as bytes and look up the code point in its cmap table. cmap maps character codes to glyph IDs, so a character missing from it cannot be drawn with that font. Treat code points mapped to glyph ID 0 (.notdef) as missing. In the browser, File.arrayBuffer() gives you the bytes without uploading anything to a server.
Why can't document.fonts.check() tell me whether a glyph exists?
Because check() answers 'can this text be rendered without using fonts that are not loaded yet', not 'does this font have the glyph'. The CSS Font Loading spec states that it returns true when the faces' unicode-range does not cover the text, and also when none of the named fonts exist. In both cases the text simply falls back to another font, so a missing glyph still yields true.
Can't I just measure the width with Canvas measureText?
For on-screen layout, yes. But the browser silently fills missing glyphs from fallback fonts, so a width that includes characters from a different font looks perfectly normal. And the font the engraving machine uses may not even be installed in the browser. To decide whether an order can be produced, sum the advance widths from the production font's own hmtx table.
Doesn't NFKC normalization solve the vendor-character problem?
No. NFKC turns ① into 1 and ㈱ into (株), so you would engrave something other than what the customer ordered. UAX #15 itself says NFKC and NFKD must not be blindly applied to arbitrary text. The engine never converts automatically; it shows the NFKC result to the shop as a suggested replacement to confirm with the buyer.
How should I handle characters with ideographic variation selectors (IVS)?
Variation selectors (U+FE00–FE0F and U+E0100–E01EF) are zero-width code points that select a glyph variant of the preceding character; they have no glyph of their own. The engine strips them before the glyph, script and length checks, so it asks whether the base character can be produced. It then raises a separate warning that a variant was requested, so the shop can confirm with the buyer.
Can I compute Japanese business days and holidays from the rules?
It is not recommended. The Public Holiday Act does not give dates for the vernal and autumnal equinox holidays; it only says 'the vernal equinox day' and 'the autumnal equinox day'. Substitute holidays and 'citizens' holidays' depend on the neighbouring holidays. Import the Cabinet Office holiday CSV, and label any result that reaches beyond the covered years as an estimate.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

React / Next.js front-end implementation, through to accessibility

The boundary between state management and data fetching, type-safe form validation, re-render control, and accessibility to WCAG 2.2 — implemented as one piece of work. Having shipped and operated a real-time multi-user scoring app where conflicts and latency were the design premise, I build front-ends that stay fast, stay correct, and stay usable by everyone.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading