grapheme-conformance
v0.1.2
Published
Score any JavaScript grapheme segmenter against Unicode's official GraphemeBreakTest.txt. Find out which string splitters are wrong, and about what.
Maintainers
Readme
grapheme-conformance
Your string splitter is probably wrong about Hindi.
Unicode publishes GraphemeBreakTest.txt: a machine-readable answer key that
says exactly where a string may be split into user-perceived characters. This
package scores any JavaScript grapheme segmenter against it, tells you which
cases it got wrong, and gives you an exit code you can put in CI.
It does not ship another segmenter.
The finding
क्षि — Devanagari, four code points, one character a reader sees. Split it
with a popular library and you get two clusters. grapheme-splitter and
graphemer both break it; runes2 breaks it into three.
| input | splits wrongly in |
|---|---|
| क्षि Devanagari (0915 094D 0937 093F) | grapheme-splitter (2), graphemer (2), runes2 (3) |
| e + combining acute (0065 0301) | runes2 (2) |
| Hangul 한 (1112 1161 11AB) | runes2 (2) |
| pirate flag (1F3F4 200D 2620 FE0F) | grapheme-splitter (2) |
Family emoji, skin-tone modifiers, regional-indicator flags, tag-sequence
flags, ZWJ professions and keycaps pass in every library tested. Emoji is not
the story; Indic script is. All seven of graphemer's Unicode 15.1 failures
are rule GB9c, added in that release for Indic conjunct clusters.
Scoreboard
See SCOREBOARD.md, regenerated by npm run scoreboard.
Cases passed:
| implementation | U15.1.0 (1187) | U16.0.0 (1093) |
|---|---|---|
| Intl.Segmenter (ICU, Node 22) | 1186 | 1092 |
| [email protected] | 1186 | 1092 |
| [email protected] | 1180 | 1086 |
| [email protected] | 1175 | 1081 |
| [email protected] | 730 | 695 |
Intl.Segmenter's single failure is the input 2701 200D 2701, a known ICU
deviation — and one that arrived with a later ICU rather than an earlier one.
Node 18.20.8 ships an ICU without it and passes every case (1187 and 1093);
Node 20 and 22 split that input and score 1186 and 1092.
Install
npm install --save-dev grapheme-conformanceZero runtime dependencies. Dual ESM/CJS. Node 18+.
CLI
npx grapheme-conformance --module unicode-segmenter/grapheme \
--export splitGraphemes --version 16.0.0unicode-segmenter/grapheme (splitGraphemes) GraphemeBreakTest 16.0.0
passed 1092/1093 99.91%
failed 1
line input want got rule
1105 2701 200D 2701 1 2 -| flag | default | meaning |
|---|---|---|
| --module | required | module to load: a bare name, or a path relative to cwd |
| --export | default | the export to score |
| --version | 16.0.0 | vendored vectors: 15.0.0, 15.1.0, 16.0.0, 17.0.0 |
| --min | 1.0 | minimum pass rate before exiting non-zero |
| --limit | 10 | failing cases to print |
Exits 0 when the pass rate is at least --min, 1 when below, 2 on a
usage or module-loading error. The exit code is the point: drop it into your
own CI. Exports returning a generator or any other iterable are accepted.
API
import { parseBreakTest, score, vectors } from 'grapheme-conformance';
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const report = score(
(s) => [...segmenter.segment(s)].map((part) => part.segment),
vectors['16.0.0'],
);
report.passed; // 1092
report.total; // 1093
report.rate; // 0.999085...
report.failures[0].inputHex; // '2701 200D 2701'type Segmenter = (input: string) => string[];
interface Vector { input: string; expected: string[]; line: number }
function parseBreakTest(source: string): Vector[];
function score(segmenter: Segmenter, vectors: Vector[]): Report;
interface Report { passed: number; total: number; rate: number; failures: Failure[] }
interface Failure {
line: number; // 1-based line number in the source .txt
input: string;
inputHex: string; // 'space-separated UPPERCASE hex code points'
expected: string[];
actual: string[];
rule: string | null; // inferred rule id, or null
}
const vectors: Record<string, Vector[]>; // keyed by Unicode versionparseBreakTest takes a string and never touches the filesystem. score never
throws: a segmenter that throws on an input yields a failure with actual: [].
failures is ordered by line, and repeated calls return deeply equal reports.
Vectors
15.0.0, 15.1.0, 16.0.0 and 17.0.0 are vendored under vectors/ and
committed, fetched once from
unicode-org/unicodetools. Nothing
is fetched at install, build or test time; the whole suite runs offline.
Rule inference
Each failure carries a best-effort rule: GB9c (Indic conjunct), GB11
(pictographic ZWJ sequence), GB12/GB13 (regional indicators), GB6/GB7/GB8
(Hangul jamo), or null.
This is a convenience for reading the scoreboard, not a correctness claim. It inspects the input for characteristic code points; it does not implement UAX #29 and does not prove which rule an implementation actually got wrong.
Intl.Segmenter and the host ICU
Intl.Segmenter is scored against whatever ICU the host Node ships, so its row
moves with the runtime, and not monotonically: Node 18.20.8 scores higher than
Node 22 because the 2701 200D 2701 deviation is a newer ICU behaviour. The
pure-JS libraries are pinned to exact versions and score identically everywhere.
Verifying this build
See VERIFY.md: one command, ten integers to compare.
Scope
Grapheme clusters only. Word and sentence breaking are out of scope —
unicode-segmenter does not implement them, which leaves too few
implementations to be worth a scoreboard.
License
MIT
