grapheme-conformance
v0.1.6
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
क्षि is 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 |
The one case Intl.Segmenter misses in both columns is 2701 200D 2701, and
it is an artefact of the answer key, not a bug in ICU. Unicode 17.0 removed
U+2701 from Extended_Pictographic, so GB11 no longer applies and the correct
result became two clusters. Scored against the 17.0.0 key, ICU on Node 22
passes every case:
| implementation | U17.0.0 (766) |
|---|---|
| Intl.Segmenter (ICU, Node 22) | 766 |
| [email protected] | 766 |
| [email protected] | 749 |
| [email protected] | 746 |
| [email protected] | 501 |
Score an implementation against the key it targets. A current segmenter judged by a superseded key reports failures it does not have.
Install
npm install --save-dev grapheme-conformanceZero runtime dependencies. Dual ESM/CJS. Node 18+.
CLI
npx grapheme-conformance --module unicode-segmenter/grapheme \
--export splitGraphemesunicode-segmenter/grapheme (splitGraphemes) GraphemeBreakTest 17.0.0
passed 766/766 100.00%
failed 0| flag | default | meaning |
|---|---|---|
| --module | required | module to load: a bare name, or a path relative to cwd |
| --export | default | the export to score |
| --version | 17.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['17.0.0'],
);
report.passed; // 766
report.total; // 766
report.rate; // 1
report.failures; // []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. Against the older keys the movement is not even
monotonic: Node 18.20.8 scores 1187 and 1093 where Node 20 and 22 score
1186 and 1092, because newer ICU implements the Unicode 17.0 property
change that those keys predate. Against the 17.0.0 key every one of them is
correct. 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
