├── .gitignore ├── .travis.yml ├── .babelrc ├── rollup.config.js ├── package.json ├── dist ├── zero-width-lib.m.js ├── zero-width-lib.umd.js └── zero-width-lib.js ├── README.md ├── src └── zero-width-lib.js └── test └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "modules": false 5 | }] 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import babel from 'rollup-plugin-babel'; 3 | import { terser } from "rollup-plugin-terser"; 4 | import pkg from './package.json'; 5 | import { basename } from 'path'; 6 | const isDev = process.env.NODE_ENV === 'development'; 7 | 8 | export default { 9 | input: pkg['source'], 10 | output: [ 11 | { 12 | file: pkg['main'], 13 | format: 'cjs', 14 | sourcemap: isDev ? true : false 15 | }, 16 | { 17 | file: pkg['module'], 18 | format: 'esm', 19 | sourcemap: isDev ? true : false 20 | }, 21 | { 22 | file: pkg['umd:main'], 23 | format: 'umd', 24 | name: basename(pkg['umd:main']).replace(/\.umd\.js$/, ''), 25 | sourcemap: isDev ? true : false 26 | } 27 | ], 28 | plugins: [ 29 | resolve(), 30 | babel({ 31 | include: 'src/**' 32 | }), 33 | terser() 34 | ], 35 | watch: { 36 | exclude: 'node_modules/**' 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zero-width-lib", 3 | "version": "1.1.0", 4 | "description": "A lib for zero width character utils", 5 | "main": "dist/zero-width-lib.js", 6 | "umd:main": "dist/zero-width-lib.umd.js", 7 | "module": "dist/zero-width-lib.m.js", 8 | "source": "src/zero-width-lib.js", 9 | "scripts": { 10 | "build": "npm run clean && NODE_ENV=production ./node_modules/.bin/rollup -c", 11 | "dev": "NODE_ENV=development ./node_modules/.bin/rollup -c -w", 12 | "clean": "rm -rf dist/*", 13 | "test": "mocha" 14 | }, 15 | "author": "Yuan Fu ", 16 | "files": [ 17 | "dist/", 18 | "README.md" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/yuanfux/zero-width-lib.git" 23 | }, 24 | "keywords": [ 25 | "zero", 26 | "width", 27 | "zero width" 28 | ], 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@babel/core": "^7.5.4", 32 | "@babel/preset-env": "^7.5.4", 33 | "mocha": "^6.1.4", 34 | "rollup": "^1.16.7", 35 | "rollup-plugin-babel": "^4.3.3", 36 | "rollup-plugin-node-resolve": "^5.2.0", 37 | "rollup-plugin-terser": "^5.1.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /dist/zero-width-lib.m.js: -------------------------------------------------------------------------------- 1 | var r="​",t={leftToRightMark:"‎",rightToLeftMark:"‏",zeroWidthNonJoiner:"‌",zeroWidthJoiner:"‍",zeroWidthNoBreakSpace:"\ufeff",zeroWidthSpace:r},n=Object.keys(t).map(function(r){return t[r]}),e=n.reduce(function(r,t,n){return r[t]=""+n,r},{});function o(t){for(var e="",o=0;o 10 | 11 |

12 | 13 | ## What's zero-width-lib 14 | Zero-width-lib is a library for manipulating zero width characters (ZWC), which are non-printing and invisible chars. 15 | 16 | The common usage of ZWC includes fingerprinting confidential text, embedding hidden text and escaping from string matching (i.e. regex)... 17 | 18 | The lib is inspired by this great [medium article](https://medium.com/@umpox/be-careful-what-you-copy-invisibly-inserting-usernames-into-text-with-zero-width-characters-18b4e6f17b66) and got the following features: 19 | 20 | 1. 💯stable & cover full test cases 21 | 2. 😆support full width Unicode chars 22 | 3. ⚡️dependencies & performance considered 23 | 4. 📦support CJS, ESM and UMD 24 | 25 | 26 | ## Install 27 | ``` 28 | npm install zero-width-lib 29 | ``` 30 | 31 | ## Usage 32 | > Besides ESM, CJS and UMD ways of importing are also supported 33 | ```javascript 34 | // import one method at a time 35 | import { encode } from 'zero-width-lib'; 36 | ``` 37 | ```javascript 38 | // or import all methods from lib 39 | import * as z from 'zero-width-lib'; 40 | ``` 41 | ```javascript 42 | // note * represents the invisible ZWC 43 | // U+ represents the Unicode for the character 44 | 45 | // 0. six different zwc 46 | const dict = z.zeroWidthDict; 47 | console.log(dict.zeroWidthSpace); // '*' U+200B 48 | console.log(dict.zeroWidthNonJoiner); // '*' U+200C 49 | console.log(dict.zeroWidthJoiner); // '*' U+200D 50 | console.log(dict.leftToRightMark); // '*' U+200E 51 | console.log(dict.rightToLeftMark); // '*' U+200F 52 | console.log(dict.zeroWidthNoBreakSpace); // '*' U+FEFF 53 | 54 | // 1. convert text 55 | const text = 'text'; 56 | const zwc = z.t2z(text); // '********' 57 | const back = z.z2t(zwc); // 'text' 58 | 59 | // 2. embed hidden text 60 | const visble = 'hello world'; 61 | const hidden = 'inspired by @umpox'; 62 | const encoded = z.encode(visible, hidden); // 'h*********ello world' 63 | const decoded = z.decode(encoded); // 'inpired by @umpox' 64 | 65 | // 3. extract ZWC from text 66 | const extracted = z.extract(encoded); 67 | const vis = extracted.vis; // 'hello world' 68 | const hid = extracted.hid; // '*********' 69 | 70 | // 4. escape from string matching 71 | const forbidden = 'forbidden'; 72 | const escaped = z.split(forbidden); // 'f*o*r*b*i*d*d*e*n*' 73 | ``` 74 | 75 | ## License 76 | MIT 77 | -------------------------------------------------------------------------------- /src/zero-width-lib.js: -------------------------------------------------------------------------------- 1 | const zeroWidthNonJoiner = '‌'; 2 | const zeroWidthJoiner = '‍'; 3 | const zeroWidthSpace = '​'; 4 | const zeroWidthNoBreakSpace = ''; 5 | const leftToRightMark = '‎'; 6 | const rightToLeftMark = '‏'; 7 | 8 | export const zeroWidthDict = { 9 | leftToRightMark, 10 | rightToLeftMark, 11 | zeroWidthNonJoiner, 12 | zeroWidthJoiner, 13 | zeroWidthNoBreakSpace, 14 | zeroWidthSpace 15 | }; 16 | 17 | const Quinary2ZeroMap = Object.keys(zeroWidthDict).map(key => zeroWidthDict[key]); 18 | const Zero2QuinaryMap = 19 | Quinary2ZeroMap.reduce((acc, cur, index) => { 20 | acc[cur] = '' + index; 21 | return acc; 22 | }, {}); 23 | 24 | export function t2z(t) { 25 | let z = ''; 26 | for (let i = 0 ; i < t.length ; i++) { 27 | const base10 = t.codePointAt(i); 28 | const base5 = base10.toString(5); 29 | let zero = ''; 30 | for (let j = 0 ; j < base5.length ; j++) { 31 | // quinary to zero width chars 32 | // may be able to extend to other base 33 | zero += Quinary2ZeroMap[+base5.charAt(j)]; 34 | } 35 | // skip low surrogate 36 | i = base10 < 0x10000 ? i : i + 1; 37 | z += i === t.length - 1 ? zero : zero + zeroWidthSpace; 38 | } 39 | return z; 40 | } 41 | 42 | export function z2t(z) { 43 | let t = ''; 44 | // return empty string when input is empty 45 | if (z.length === 0) { 46 | return t; 47 | } 48 | const chars = z.split(zeroWidthSpace); 49 | for (let i = 0 ; i < chars.length ; i++) { 50 | let base5 = ''; 51 | for (let j = 0 ; j < chars[i].length ; j++) { 52 | base5 += Zero2QuinaryMap[chars[i].charAt(j)]; 53 | } 54 | t += String.fromCodePoint(parseInt(base5, 5)); 55 | } 56 | return t; 57 | } 58 | 59 | export function encode(vis, hid) { 60 | let e = ''; 61 | // convert hidden text to zero width chars 62 | const hid2z = t2z(hid); 63 | // if visible text is empty 64 | // return zero width chars directly 65 | if (vis.length === 0) { 66 | return hid2z; 67 | } 68 | // otherwise insert zero width chars 69 | // after the first character 70 | // try to prevent user from not copying zero width chars 71 | let isAdded = false; 72 | for (const ch of vis) { 73 | e += ch; 74 | if (!isAdded) { 75 | e += hid2z; 76 | isAdded = true; 77 | } 78 | } 79 | return e; 80 | } 81 | 82 | export function extract(t) { 83 | let vis = ''; 84 | let hid = ''; 85 | for (const ch of t) { 86 | if (Zero2QuinaryMap[ch]) { 87 | hid += ch; 88 | } else { 89 | vis += ch; 90 | } 91 | } 92 | return { 93 | vis, 94 | hid 95 | } 96 | } 97 | 98 | export function decode(vis) { 99 | // decode a visible text 100 | return z2t(extract(vis).hid); 101 | } 102 | 103 | export function split(t) { 104 | // split text with zero width chars 105 | let s = ''; 106 | for (const ch of t) { 107 | s += ch; 108 | s += zeroWidthSpace; 109 | } 110 | return s; 111 | } 112 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var zeroWidthLib = require('../dist/zero-width-lib'); 3 | 4 | // import lib 5 | var zeroWidthDict = zeroWidthLib.zeroWidthDict; 6 | var Quinary2ZeroMap = Object.keys(zeroWidthDict).map(key => zeroWidthDict[key]); 7 | var t2z = zeroWidthLib.t2z; 8 | var z2t = zeroWidthLib.z2t; 9 | var encode = zeroWidthLib.encode; 10 | var extract = zeroWidthLib.extract; 11 | var decode = zeroWidthLib.decode; 12 | var split = zeroWidthLib.split; 13 | var maxTime = 20000; 14 | 15 | // construct a test string 16 | var input = ''; 17 | 18 | // partial testcase 19 | var testCodeArr = []; 20 | for (var i = 8100 ; i <= 8300 ; i++) { 21 | // case less than 65536 22 | // include some zero width characters 23 | testCodeArr.push(i); 24 | } 25 | for (var i = 65536 ; i <= 65736 ; i++) { 26 | // case greater than 65536 27 | // include some 2 width characters 28 | testCodeArr.push(i); 29 | } 30 | for (var i = 0 ; i < testCodeArr.length ; i++) { 31 | input += String.fromCodePoint(testCodeArr[i]); 32 | } 33 | 34 | // full testcase 35 | // for (var i = 0 ; i <= 0x10FFFF ; i++) { 36 | // input += String.fromCodePoint(i); 37 | // } 38 | 39 | var regexStr = Quinary2ZeroMap.join('|'); 40 | var re = new RegExp(regexStr, 'g'); 41 | // construct another test string, which doesn't contain 6 zero width characters used in the lib 42 | var pureInput = input.replace(re, ''); 43 | 44 | describe('zero-width-lib', function() { 45 | describe('#zeroWidthDict', function() { 46 | it('Zero width characters in the dictionary should be unique.', function() { 47 | const uniqueMap = {}; 48 | let isUnique = true; 49 | for (const ch of Object.keys(zeroWidthDict)) { 50 | if(uniqueMap[zeroWidthDict[ch]]) { 51 | isUnique = false; 52 | break; 53 | } else { 54 | uniqueMap[zeroWidthDict[ch]] = 1; 55 | } 56 | } 57 | assert.equal(isUnique, true); 58 | }); 59 | }); 60 | describe('#t2z()', function() { 61 | it('Should return empty string when input is empty.', function() { 62 | assert.equal('', t2z('')); 63 | }); 64 | }); 65 | describe('#z2t()', function() { 66 | it('Should return empty string when input is empty.', function() { 67 | assert.equal('', z2t('')); 68 | }); 69 | }); 70 | describe('#t2z() & #z2t()', function() { 71 | this.timeout(maxTime); 72 | it('After converting and converting back, the text should be the same.', function() { 73 | var output = z2t(t2z(input)); 74 | assert.equal(input, output); 75 | }); 76 | }); 77 | describe('#encode()', function() { 78 | this.timeout(maxTime); 79 | it('Should return same string as #t2z() returns when visible text is empty.', function() { 80 | assert.equal(t2z(input), encode('', input)); 81 | }); 82 | it('Should return same string as visible returns when hidden text is empty.', function() { 83 | assert.equal(input, encode(input, '')); 84 | }); 85 | it('Should return empty string when input is empty.', function() { 86 | assert.equal('', encode('', '')); 87 | }); 88 | }); 89 | describe('#extract', function() { 90 | this.timeout(maxTime); 91 | it('Should return an object with empty vis & hid strings when input is empty.', function() { 92 | assert.equal('', extract('').hid); 93 | assert.equal('', extract('').vis); 94 | }); 95 | var extracted = extract(encode(pureInput, pureInput)); 96 | it('After extracting, visible text should not contain any zero width character.', function() { 97 | assert.equal(re.test(extracted.vis), false); 98 | }); 99 | it('After extracting, hidden text should only contain zero width characters.', function() { 100 | var reExtract = new RegExp('^[' + Quinary2ZeroMap.join('') + ']*$'); 101 | assert.equal(reExtract.test(extracted.hid), true); 102 | }); 103 | }); 104 | describe('#decode()', function() { 105 | it('Should return empty string when input text is empty.', function() { 106 | assert.equal('', decode('')); 107 | }); 108 | }); 109 | describe('#encode() & #decode()', function() { 110 | this.timeout(maxTime); 111 | it('After encoding and decoding, the text should be the same.', function() { 112 | var decoded = decode(encode(pureInput, pureInput)); 113 | assert.equal(pureInput, decoded); 114 | }); 115 | }); 116 | describe('#split()', function() { 117 | this.timeout(maxTime); 118 | it('Every character should be split with zero width character.', function() { 119 | var splitted = split(input); 120 | var splitArr = Array.from(splitted); 121 | for (var i = 0 ; i < splitArr.length ; i++) { 122 | if (i % 2) { 123 | assert.equal(splitArr[i], zeroWidthDict.zeroWidthSpace); 124 | } 125 | } 126 | }); 127 | it('Should return empty string when input text is empty.', function() { 128 | assert.equal('', split('')); 129 | }); 130 | }); 131 | }); --------------------------------------------------------------------------------