├── .editorconfig ├── .gitattributes ├── .github ├── security.md └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── index.d.ts ├── index.js ├── index.test-d.ts ├── license ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/security.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). Tidelift will coordinate the fix and disclosure. 4 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type Options = { 2 | /** 3 | Count [ambiguous width characters](https://www.unicode.org/reports/tr11/#Ambiguous) as having narrow width (count of 1) instead of wide width (count of 2). 4 | 5 | @default true 6 | 7 | > Ambiguous characters behave like wide or narrow characters depending on the context (language tag, script identification, associated font, source of data, or explicit markup; all can provide the context). __If the context cannot be established reliably, they should be treated as narrow characters by default.__ 8 | > - http://www.unicode.org/reports/tr11/ 9 | */ 10 | readonly ambiguousIsNarrow?: boolean; 11 | 12 | /** 13 | Whether [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) should be counted. 14 | 15 | @default false 16 | */ 17 | readonly countAnsiEscapeCodes?: boolean; 18 | }; 19 | 20 | /** 21 | Get the visual width of a string - the number of columns required to display it. 22 | 23 | Some Unicode characters are [fullwidth](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms) and use double the normal width. [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) are stripped and doesn't affect the width. 24 | 25 | @example 26 | ``` 27 | import stringWidth from 'string-width'; 28 | 29 | stringWidth('a'); 30 | //=> 1 31 | 32 | stringWidth('古'); 33 | //=> 2 34 | 35 | stringWidth('\u001B[1m古\u001B[22m'); 36 | //=> 2 37 | ``` 38 | */ 39 | export default function stringWidth(string: string, options?: Options): number; 40 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import stripAnsi from 'strip-ansi'; 2 | import {eastAsianWidth} from 'get-east-asian-width'; 3 | import emojiRegex from 'emoji-regex'; 4 | 5 | const segmenter = new Intl.Segmenter(); 6 | 7 | const defaultIgnorableCodePointRegex = /^\p{Default_Ignorable_Code_Point}$/u; 8 | 9 | export default function stringWidth(string, options = {}) { 10 | if (typeof string !== 'string' || string.length === 0) { 11 | return 0; 12 | } 13 | 14 | const { 15 | ambiguousIsNarrow = true, 16 | countAnsiEscapeCodes = false, 17 | } = options; 18 | 19 | if (!countAnsiEscapeCodes) { 20 | string = stripAnsi(string); 21 | } 22 | 23 | if (string.length === 0) { 24 | return 0; 25 | } 26 | 27 | let width = 0; 28 | const eastAsianWidthOptions = {ambiguousAsWide: !ambiguousIsNarrow}; 29 | 30 | for (const {segment: character} of segmenter.segment(string)) { 31 | const codePoint = character.codePointAt(0); 32 | 33 | // Ignore control characters 34 | if (codePoint <= 0x1F || (codePoint >= 0x7F && codePoint <= 0x9F)) { 35 | continue; 36 | } 37 | 38 | // Ignore zero-width characters 39 | if ( 40 | (codePoint >= 0x20_0B && codePoint <= 0x20_0F) // Zero-width space, non-joiner, joiner, left-to-right mark, right-to-left mark 41 | || codePoint === 0xFE_FF // Zero-width no-break space 42 | ) { 43 | continue; 44 | } 45 | 46 | // Ignore combining characters 47 | if ( 48 | (codePoint >= 0x3_00 && codePoint <= 0x3_6F) // Combining diacritical marks 49 | || (codePoint >= 0x1A_B0 && codePoint <= 0x1A_FF) // Combining diacritical marks extended 50 | || (codePoint >= 0x1D_C0 && codePoint <= 0x1D_FF) // Combining diacritical marks supplement 51 | || (codePoint >= 0x20_D0 && codePoint <= 0x20_FF) // Combining diacritical marks for symbols 52 | || (codePoint >= 0xFE_20 && codePoint <= 0xFE_2F) // Combining half marks 53 | ) { 54 | continue; 55 | } 56 | 57 | // Ignore surrogate pairs 58 | if (codePoint >= 0xD8_00 && codePoint <= 0xDF_FF) { 59 | continue; 60 | } 61 | 62 | // Ignore variation selectors 63 | if (codePoint >= 0xFE_00 && codePoint <= 0xFE_0F) { 64 | continue; 65 | } 66 | 67 | // This covers some of the above cases, but we still keep them for performance reasons. 68 | if (defaultIgnorableCodePointRegex.test(character)) { 69 | continue; 70 | } 71 | 72 | // TODO: Use `/\p{RGI_Emoji}/v` when targeting Node.js 20. 73 | if (emojiRegex().test(character)) { 74 | width += 2; 75 | continue; 76 | } 77 | 78 | width += eastAsianWidth(codePoint, eastAsianWidthOptions); 79 | } 80 | 81 | return width; 82 | } 83 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectType} from 'tsd'; 2 | import stringWidth from './index.js'; 3 | 4 | expectType(stringWidth('古')); 5 | expectType(stringWidth('★', {})); 6 | expectType(stringWidth('★', {ambiguousIsNarrow: false})); 7 | expectType(stringWidth('\u001B[31m\u001B[39m', {countAnsiEscapeCodes: true})); 8 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "string-width", 3 | "version": "7.2.0", 4 | "description": "Get the visual width of a string - the number of columns required to display it", 5 | "license": "MIT", 6 | "repository": "sindresorhus/string-width", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=18" 21 | }, 22 | "scripts": { 23 | "test": "xo && ava && tsd" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "string", 31 | "character", 32 | "unicode", 33 | "width", 34 | "visual", 35 | "column", 36 | "columns", 37 | "fullwidth", 38 | "full-width", 39 | "full", 40 | "ansi", 41 | "escape", 42 | "codes", 43 | "cli", 44 | "command-line", 45 | "terminal", 46 | "console", 47 | "cjk", 48 | "chinese", 49 | "japanese", 50 | "korean", 51 | "fixed-width", 52 | "east-asian-width" 53 | ], 54 | "dependencies": { 55 | "emoji-regex": "^10.3.0", 56 | "get-east-asian-width": "^1.0.0", 57 | "strip-ansi": "^7.1.0" 58 | }, 59 | "devDependencies": { 60 | "ava": "^5.3.1", 61 | "tsd": "^0.29.0", 62 | "xo": "^0.56.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # string-width 2 | 3 | > Get the visual width of a string - the number of columns required to display it 4 | 5 | Some Unicode characters are [fullwidth](https://en.wikipedia.org/wiki/Halfwidth_and_fullwidth_forms) and use double the normal width. [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) are stripped and doesn't affect the width. 6 | 7 | Useful to be able to measure the actual width of command-line output. 8 | 9 | ## Install 10 | 11 | ```sh 12 | npm install string-width 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```js 18 | import stringWidth from 'string-width'; 19 | 20 | stringWidth('a'); 21 | //=> 1 22 | 23 | stringWidth('古'); 24 | //=> 2 25 | 26 | stringWidth('\u001B[1m古\u001B[22m'); 27 | //=> 2 28 | ``` 29 | 30 | ## API 31 | 32 | ### stringWidth(string, options?) 33 | 34 | #### string 35 | 36 | Type: `string` 37 | 38 | The string to be counted. 39 | 40 | #### options 41 | 42 | Type: `object` 43 | 44 | ##### ambiguousIsNarrow 45 | 46 | Type: `boolean`\ 47 | Default: `true` 48 | 49 | Count [ambiguous width characters](https://www.unicode.org/reports/tr11/#Ambiguous) as having narrow width (count of 1) instead of wide width (count of 2). 50 | 51 | > Ambiguous characters behave like wide or narrow characters depending on the context (language tag, script identification, associated font, source of data, or explicit markup; all can provide the context). **If the context cannot be established reliably, they should be treated as narrow characters by default.** 52 | > - http://www.unicode.org/reports/tr11/ 53 | 54 | ##### countAnsiEscapeCodes 55 | 56 | Type: `boolean`\ 57 | Default: `false` 58 | 59 | Whether [ANSI escape codes](https://en.wikipedia.org/wiki/ANSI_escape_code) should be counted. 60 | 61 | ## Related 62 | 63 | - [string-width-cli](https://github.com/sindresorhus/string-width-cli) - CLI for this module 64 | - [string-length](https://github.com/sindresorhus/string-length) - Get the real length of a string 65 | - [widest-line](https://github.com/sindresorhus/widest-line) - Get the visual width of the widest line in a string 66 | - [get-east-asian-width](https://github.com/sindresorhus/get-east-asian-width) - Determine the East Asian Width of a Unicode character 67 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import stringWidth from './index.js'; 3 | 4 | test('main', t => { 5 | t.is(stringWidth('⛣', {ambiguousIsNarrow: false}), 2); 6 | t.is(stringWidth('abcde'), 5); 7 | t.is(stringWidth('古池や'), 6); 8 | t.is(stringWidth('あいうabc'), 9); 9 | t.is(stringWidth('あいう★'), 7); 10 | t.is(stringWidth('あいう★', {ambiguousIsNarrow: false}), 8); 11 | t.is(stringWidth('±'), 1); 12 | t.is(stringWidth('ノード.js'), 9); 13 | t.is(stringWidth('你好'), 4); 14 | t.is(stringWidth('안녕하세요'), 10); 15 | t.is(stringWidth('A\uD83C\uDE00BC'), 5, 'surrogate'); 16 | t.is(stringWidth('\u001B[31m\u001B[39m'), 0); 17 | t.is(stringWidth('\u001B[31m\u001B[39m', {countAnsiEscapeCodes: true}), 8); 18 | t.is(stringWidth('\u001B]8;;https://github.com\u0007Click\u001B]8;;\u0007'), 5); 19 | t.is(stringWidth('\u{231A}'), 2, '⌚ default emoji presentation character (Emoji_Presentation)'); 20 | t.is(stringWidth('\u{2194}\u{FE0F}'), 2, '↔️ default text presentation character rendered as emoji'); 21 | t.is(stringWidth('\u{1F469}'), 2, '👩 emoji modifier base (Emoji_Modifier_Base)'); 22 | t.is(stringWidth('\u{1F469}\u{1F3FF}'), 2, '👩🏿 emoji modifier base followed by a modifier'); 23 | t.is(stringWidth('\u{845B}\u{E0100}'), 2, 'Variation Selectors'); 24 | t.is(stringWidth('ปฏัก'), 3, 'Thai script'); 25 | t.is(stringWidth('_\u0E34'), 1, 'Thai script'); 26 | t.is(stringWidth('“', {ambiguousIsNarrow: false}), 2); 27 | }); 28 | 29 | test('ignores control characters', t => { 30 | t.is(stringWidth(String.fromCodePoint(0)), 0); 31 | t.is(stringWidth(String.fromCodePoint(31)), 0); 32 | t.is(stringWidth(String.fromCodePoint(127)), 0); 33 | t.is(stringWidth(String.fromCodePoint(134)), 0); 34 | t.is(stringWidth(String.fromCodePoint(159)), 0); 35 | t.is(stringWidth('\u001B'), 0); 36 | }); 37 | 38 | test('handles combining characters', t => { 39 | t.is(stringWidth('x\u0300'), 1); 40 | t.is(stringWidth('\u0300\u0301'), 0); 41 | t.is(stringWidth('e\u0301e'), 2); 42 | t.is(stringWidth('x\u036F'), 1); 43 | t.is(stringWidth('\u036F\u036F'), 0); 44 | }); 45 | 46 | test('handles ZWJ characters', t => { 47 | t.is(stringWidth('👶'), 2); 48 | t.is(stringWidth('👶🏽'), 2); 49 | t.is(stringWidth('👩‍👩‍👦‍👦'), 2); 50 | t.is(stringWidth('👨‍❤️‍💋‍👨'), 2); 51 | }); 52 | 53 | test('handles zero-width characters', t => { 54 | t.is(stringWidth('\u200B'), 0); 55 | t.is(stringWidth('x\u200Bx'), 2); 56 | t.is(stringWidth('\u200C'), 0); 57 | t.is(stringWidth('x\u200Cx'), 2); 58 | t.is(stringWidth('\u200D'), 0); 59 | t.is(stringWidth('x\u200Dx'), 2); 60 | t.is(stringWidth('\uFEFF'), 0); 61 | t.is(stringWidth('x\uFEFFx'), 2); 62 | }); 63 | 64 | test('handles surrogate pairs', t => { 65 | t.is(stringWidth('\uD83D\uDE00'), 2); // 😀 66 | t.is(stringWidth('A\uD83D\uDE00B'), 4); 67 | }); 68 | 69 | test('handles variation selectors', t => { 70 | t.is(stringWidth('\u{1F1E6}\uFE0F'), 1); // Regional indicator symbol A with variation selector 71 | t.is(stringWidth('A\uFE0F'), 1); 72 | t.is(stringWidth('\uFE0F'), 0); 73 | }); 74 | 75 | test('handles edge cases', t => { 76 | t.is(stringWidth(''), 0); 77 | t.is(stringWidth('\u200B\u200B'), 0); 78 | t.is(stringWidth('x\u200Bx\u200B'), 2); 79 | t.is(stringWidth('x\u0300x\u0300'), 2); 80 | t.is(stringWidth('\uD83D\uDE00\uFE0F'), 2); // 😀 with variation selector 81 | t.is(stringWidth('\uD83D\uDC69\u200D\uD83C\uDF93'), 2); // 👩‍🎓 82 | t.is(stringWidth('x\u1AB0x\u1AB0'), 2); // Combining diacritical marks extended 83 | t.is(stringWidth('x\u1DC0x\u1DC0'), 2); // Combining diacritical marks supplement 84 | t.is(stringWidth('x\u20D0x\u20D0'), 2); // Combining diacritical marks for symbols 85 | t.is(stringWidth('x\uFE20x\uFE20'), 2); // Combining half marks 86 | }); 87 | 88 | test('ignores default ignorable code points', t => { 89 | t.is(stringWidth('\u2060'), 0); // Word joiner 90 | t.is(stringWidth('\u2061'), 0); // Function application 91 | t.is(stringWidth('\u2062'), 0); // Invisible times 92 | t.is(stringWidth('\u2063'), 0); // Invisible separator 93 | t.is(stringWidth('\u2064'), 0); // Invisible plus 94 | t.is(stringWidth('\uFEFF'), 0); // Zero-width no-break space 95 | t.is(stringWidth('x\u2060x'), 2); 96 | t.is(stringWidth('x\u2061x'), 2); 97 | t.is(stringWidth('x\u2062x'), 2); 98 | t.is(stringWidth('x\u2063x'), 2); 99 | t.is(stringWidth('x\u2064x'), 2); 100 | }); 101 | --------------------------------------------------------------------------------