Skip to content

ne-ooo/neo.string-width

Repository files navigation

@lpm.dev/neo.string-width

Modern, ultra-fast string width calculator with zero dependencies

Calculate the visual width of strings for terminal/console output. Correctly handles Unicode, emoji, CJK characters, and ANSI escape codes.


Why This Package?

🚀 Blazing Fast

  • 6.8x faster on ASCII text than string-width
  • 1.2x faster on CJK text than string-width
  • 1.8x faster on emoji than string-width

✨ Feature Rich

  • Built-in helper functions: truncate, pad, slice, split by width
  • Full TypeScript support with strict types
  • Tree-shakeable (import only what you need)

🎯 Zero Dependencies

  • Only dependency: @lpm.dev/neo.ansi (itself zero-dep)
  • Smaller bundle than alternatives

💯 Correct

  • Unicode 16 compliant
  • Handles all edge cases (emoji with skin tones, ZWJ sequences, combining marks)
  • 328 comprehensive tests (99.55% coverage)

Installation

lpm install @lpm.dev/neo.string-width

Quick Start

import stringWidth from "@lpm.dev/neo.string-width";

// ASCII text
stringWidth("hello"); // => 5

// CJK characters (fullwidth)
stringWidth("你好"); // => 4 (2 chars × 2 width)

// Emoji
stringWidth("👍"); // => 2

// ANSI colored text (codes are stripped)
stringWidth("\x1b[31mRed\x1b[0m"); // => 3

// Mixed content
stringWidth("Hello 你好 👍"); // => 11

API

stringWidth(text, options?)

Calculate the visual width of a string.

Parameters:

  • text (string): Text to measure
  • options (object, optional):
    • ambiguousIsNarrow (boolean): Treat ambiguous-width characters as narrow (default: true)
    • countAnsiEscapeCodes (boolean): Count ANSI codes in width (default: false)
    • emojiWidth (number | 'auto'): Width for emoji (default: 2)
    • normalize (boolean): Normalize text before measuring (default: false)

Returns: number - Visual width in columns

import stringWidth from "@lpm.dev/neo.string-width";

stringWidth("hello"); // => 5
stringWidth("你好世界"); // => 8
stringWidth("👍👎"); // => 4

Helper Functions

truncateToWidth(text, width, options?)

Truncate text to fit within a specified width.

import { truncateToWidth } from "@lpm.dev/neo.string-width";

// End truncation (default)
truncateToWidth("Hello World", 8);
// => 'Hello W…'

// Start truncation
truncateToWidth("Hello World", 8, { position: "start" });
// => '…o World'

// Middle truncation
truncateToWidth("Hello World", 8, { position: "middle" });
// => 'Hel…orld'

// Custom ellipsis
truncateToWidth("Hello World", 8, { ellipsis: "..." });
// => 'Hello...'

// Word boundary preference
truncateToWidth("Hello World Test", 10, { wordBoundary: true });
// => 'Hello…'

Options:

  • position: 'end' | 'start' | 'middle' (default: 'end')
  • ellipsis: string (default: '…')
  • wordBoundary: boolean (default: false)

padToWidth(text, width, options?)

Pad text to reach a specified width.

import { padToWidth } from "@lpm.dev/neo.string-width";

// Right padding (default)
padToWidth("Hello", 10);
// => 'Hello     '

// Left padding
padToWidth("Hello", 10, { align: "right" });
// => '     Hello'

// Center padding
padToWidth("Hello", 10, { align: "center" });
// => '  Hello   '

// Custom pad character
padToWidth("Hello", 10, { padChar: "." });
// => 'Hello.....'

// Works with CJK
padToWidth("你好", 10);
// => '你好      '

Options:

  • align: 'left' | 'right' | 'center' (default: 'left')
  • padChar: string (default: ' ')

sliceByWidth(text, start, end?)

Slice text by visual width (like String.slice but width-aware).

import { sliceByWidth } from "@lpm.dev/neo.string-width";

// Basic slicing
sliceByWidth("Hello World", 0, 5);
// => 'Hello'

// Negative indices
sliceByWidth("Hello World", -5);
// => 'World'

// Works with CJK (width-based, not character-based)
sliceByWidth("你好世界", 0, 4);
// => '你好' (2 chars, width 4)

// Mixed content
sliceByWidth("Hello 你好", 0, 7);
// => 'Hello 你'

splitByWidth(text, maxWidth, options?)

Split text into chunks that fit within a maximum width.

import { splitByWidth } from "@lpm.dev/neo.string-width";

// Basic splitting
splitByWidth("Hello World", 5);
// => ['Hello', 'World']

// CJK text
splitByWidth("你好世界测试", 8);
// => ['你好世界', '测试']

// Word boundary preference
splitByWidth("Hello World Test", 10, { preferSplitOnSpace: true });
// => ['Hello', 'World Test']

// Preserve whitespace
splitByWidth("  Hello  ", 10, { preserveWhitespace: true });
// => ['  Hello  ']

Options:

  • preferSplitOnSpace: boolean (default: true)
  • preserveWhitespace: boolean (default: false)

Unicode Support

East Asian Width Categories

Correctly handles all UAX #11 categories:

Category Width Examples
Fullwidth (F) 2 ,、。!
Wide (W) 2 你好世界 (CJK)
Halfwidth (H) 1 アイウエオ
Narrow (Na) 1 hello
Ambiguous (A) 1* ±§¶
Neutral (N) 1 hello

* Configurable via ambiguousIsNarrow option

Emoji Support

  • ✅ Simple emoji: 👍 (width 2)
  • ✅ Emoji with skin tones: 👍🏻 (width 2, single grapheme)
  • ✅ ZWJ sequences: 👨‍👩‍👧‍👦 (width 2, single grapheme)
  • ✅ Variation selectors: ❤️ vs ❤︎
  • ✅ Emoji presentation: automatic detection

Special Characters

  • ✅ Zero-width characters (ZWSP, ZWNJ, ZWJ, etc.)
  • ✅ Combining marks (é = e + combining acute)
  • ✅ ANSI escape codes (automatically stripped)
  • ✅ Grapheme clusters (proper Unicode segmentation)

Performance

Benchmarks vs string-width

Test Case neo.string-width string-width Result
ASCII 17.4M ops/sec 2.5M ops/sec 6.8x faster 🚀
CJK 2.5M ops/sec 2.1M ops/sec 1.2x faster
Emoji 3.0M ops/sec 1.6M ops/sec 1.8x faster

How? Smart fast-path optimization that avoids expensive grapheme segmentation for simple text (80%+ of real-world cases).


Bundle Size

@lpm.dev/neo.string-width: 13.70 KB (ESM)
string-width + dependencies: ~17 KB

Savings: ~20% smaller

Tree-shakeable: Import only what you need!

// Import everything (13.70 KB)
import stringWidth, {
  truncateToWidth,
  padToWidth,
} from "@lpm.dev/neo.string-width";

// Import only stringWidth (~8 KB)
import stringWidth from "@lpm.dev/neo.string-width";

// Import only helpers you need
import { truncateToWidth } from "@lpm.dev/neo.string-width";

TypeScript

Fully typed with strict TypeScript support:

import stringWidth, {
  type StringWidthOptions,
  type TruncateOptions,
  type PadOptions,
  type SliceOptions,
  type SplitOptions,
} from "@lpm.dev/neo.string-width";

// Type-safe options
const options: StringWidthOptions = {
  ambiguousIsNarrow: false,
  emojiWidth: 1,
};

stringWidth("text", options); // number

Real-World Examples

Terminal Table Formatting

import { padToWidth, truncateToWidth } from "@lpm.dev/neo.string-width";

function formatTableRow(name: string, value: string) {
  const nameCol = padToWidth(truncateToWidth(name, 20), 20);
  const valueCol = padToWidth(value, 30, { align: "right" });
  return `│ ${nameCol}${valueCol} │`;
}

formatTableRow("User Name", "你好世界");
// => '│ User Name            │                      你好世界 │'

Progress Bar

import { padToWidth } from "@lpm.dev/neo.string-width";

function progressBar(percent: number, width: number = 40) {
  const filled = Math.floor((width * percent) / 100);
  const bar = "█".repeat(filled) + "░".repeat(width - filled);
  return `[${bar}] ${percent}%`;
}

progressBar(75, 20);
// => '[███████████████░░░░] 75%'

Text Wrapping

import { splitByWidth } from "@lpm.dev/neo.string-width";

function wrapText(text: string, maxWidth: number): string[] {
  return splitByWidth(text, maxWidth, { preferSplitOnSpace: true });
}

wrapText("这是一段很长的中文文本需要被分割", 20);
// => ['这是一段很长的中文文', '本需要被分割']

Truncate File Paths

import { truncateToWidth } from "@lpm.dev/neo.string-width";

function truncatePath(path: string, maxWidth: number) {
  return truncateToWidth(path, maxWidth, { position: "middle" });
}

truncatePath("/very/long/path/to/some/file.txt", 25);
// => '/very/long/…some/file.txt'

Migration from string-width

Drop-in replacement in most cases:

// Before
import stringWidth from "string-width";
const width = stringWidth("text");

// After
import stringWidth from "@lpm.dev/neo.string-width";
const width = stringWidth("text"); // Same API!

See MIGRATION.md for detailed migration guide.


How It Works

  1. ANSI Stripping: Remove escape codes using @lpm.dev/neo.ansi
  2. ASCII Fast Path: Return text.length for ASCII-only (6.8x speedup)
  3. Simple Unicode Fast Path: Code-point iteration for simple CJK/emoji (20x speedup)
  4. Complex Grapheme Path: Use Intl.Segmenter only when needed (ZWJ, combining marks)
  5. Width Calculation: East Asian Width lookup + emoji detection

Smart Optimization: 80%+ of real-world text uses fast paths, avoiding expensive operations.


Browser Support

  • ✅ Node.js 18+
  • ✅ Modern browsers (Chrome 87+, Firefox 90+, Safari 14.1+)
  • ✅ Requires Intl.Segmenter for complex grapheme clusters

Testing

npm test              # Run all tests
npm run test:coverage # Coverage report (99.55%)
npm run bench         # Performance benchmarks

Coverage: 328 tests, 99.55% code coverage


License

MIT


FAQ

Q: Why is this faster than string-width?

A: Smart fast-path optimization. We avoid expensive Intl.Segmenter for 80%+ of text by detecting when it's not needed (simple CJK, simple emoji, ASCII).

Q: Is this a drop-in replacement for string-width?

A: Yes, in most cases! Same API for the core stringWidth() function. Plus bonus helper functions.

Q: What about emoji support?

A: Full support including skin tones (👍🏻), ZWJ sequences (👨‍👩‍👧‍👦), and variation selectors (❤️).

Q: Does it work in browsers?

A: Yes! Requires browsers with Intl.Segmenter support (Chrome 87+, Firefox 90+, Safari 14.1+).

Q: Why create another string-width package?

A: To provide better performance, built-in helpers, zero dependencies, and TypeScript-first design.

About

Modern string width calculator for terminals

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors