DEV Community

Joe Lin for BeGoodTool.com

Posted on

Counting text is harder than it looks (especially once Chinese and emoji show up)

I used to think a word counter was the most boring tool imaginable. Split on spaces, maybe trim a newline, done.

That illusion lasted right up until I had to handle Chinese text, mixed Chinese+English text, and emoji in the same input box. Once you do that, even the question "what counts as one character?" stops being obvious.

"Word count" breaks the moment spaces stop meaning words

The core decision in this tool is that it doesn't treat every language like English. Instead of assuming whitespace-separated words are the main unit, it checks how much of the input is CJK and switches the highlighted metric when that ratio gets high enough:

const CJK_RANGE =
  /[⺀-⻿぀-ヿ㐀-䶿一-鿿豈-﫿가-힣]/;

const cjkCharCount = computed(
  () => graphemes.value.filter((ch) => CJK_RANGE.test(ch)).length,
);

const isCjkDominant = computed(() => {
  const nonSpace = charCountNoSpaces.value;
  if (nonSpace === 0) return false;
  return cjkCharCount.value / nonSpace >= 0.35;
});

const wordCount = computed(() => {
  const trimmed = (inputText.value || "").trim();
  if (!trimmed) return 0;
  return trimmed.split(/\s+/).filter(Boolean).length;
});
Enter fullscreen mode Exit fullscreen mode

That 0.35 threshold is the interesting part. The tool is not trying to do true linguistic segmentation. It's making a practical UI decision: if enough of the non-space characters are CJK, then "characters" is probably the more honest primary unit than "words."

That's also why mixed text behaves reasonably. A sentence like 今天 shipping fix to prod still shows both numbers, but the tool decides which one deserves the visual emphasis instead of pretending one rule fits every script.

It avoids the classic JavaScript .length trap — mostly

If this were using plain string .length, emoji and other non-BMP characters would get overcounted because JavaScript strings are UTF-16 under the hood. The component explicitly avoids that:

const graphemes = computed(() => Array.from(inputText.value || ""));

const charCountWithSpaces = computed(() => graphemes.value.length);

const charCountNoSpaces = computed(
  () => graphemes.value.filter((ch) => !/\s/.test(ch)).length,
);
Enter fullscreen mode Exit fullscreen mode

Array.from() iterates by Unicode code point, which is already much better than counting UTF-16 code units. A single 😀 won't accidentally become 2 characters.

I also like that the no-space count is derived from the same array instead of from a second regex-heavy pass over the raw string. It keeps the counting model consistent: first normalize into countable units, then filter those units.

Reading time, sentences, and paragraphs are all heuristics too

The tool doesn't just count text units. It also changes the reading-time formula depending on the detected script, and it uses lightweight regex rules for sentences and paragraphs:

const CJK_CHARS_PER_MINUTE = 300;
const WORDS_PER_MINUTE = 200;

const readingTimeSeconds = computed(() => {
  if (isCjkDominant.value) {
    return Math.ceil((charCountNoSpaces.value / CJK_CHARS_PER_MINUTE) * 60);
  }
  return Math.ceil((wordCount.value / WORDS_PER_MINUTE) * 60);
});

const sentenceCount = computed(() => {
  const trimmed = (inputText.value || "").trim();
  if (!trimmed) return 0;
  return trimmed
    .split(/[.!?。!?…]+/)
    .map((s) => s.trim())
    .filter(Boolean).length;
});

const paragraphCount = computed(() => {
  const text = inputText.value || "";
  if (!text.trim()) return 0;
  return text
    .split(/\n\s*\n+/)
    .map((s) => s.trim())
    .filter(Boolean).length;
});
Enter fullscreen mode Exit fullscreen mode

This is the part I think most counters quietly hand-wave away. "Reading time" is not a universal constant, and even sentence counting depends on punctuation conventions. Here the code at least makes the tradeoff explicit: CJK gets characters-per-minute, space-delimited languages get words-per-minute, and paragraphs mean blank-line-separated blocks.

Where it still gets fuzzy

There are a couple of honest limitations in the current implementation.

First, graphemes is a slightly misleading variable name. Array.from() counts code points, not full grapheme clusters. That means some visually single characters — like family emoji, flags, or emoji plus skin-tone modifiers — can still count as more than one.

Second, sentence splitting is intentionally simple. A regex like /[.!?。!?…]+/ will also split on things that are not really sentence boundaries, like 3.14 or e.g.. And the CJK detection threshold is still a heuristic, so a mixed-language paragraph near 35% can flip the "primary" unit in a way that feels a little arbitrary.

I wrapped that logic into a small free tool: Word & Character Counter.


Available in other languages

Top comments (0)