সরাসরি পর্বে যান
পর্ব ০৯ · ৫৯-এর মধ্যে · মডিউল ২

string, template literal আর বাংলা Unicode

Strings, Template Literals & Bangla Unicode — UTF-16 Code Units, Code Points and Graphemes

Taught in Bangla, this lesson explores JavaScript strings as immutable sequences of UTF-16 code units: template literals, indexing with at, slice and substring, everyday search, replace and trim methods, comparison with localeCompare, and a deep look at Bangla text through code units, code points and graphemes, plus normalization and Bangla digits.

এই পর্বে যা শিখবেন

  • string আসলে কী — অপরিবর্তনীয় UTF-16 code unit-এর সারি, আর কেন প্রতিটি method নতুন string দেয়
  • তিন রকম উদ্ধৃতিচিহ্ন, escape sequence আর template literal — কোনটি কখন
  • রোজকার method: at, slice, includes, trim, padEnd, split/join, replaceAll — ফোন নম্বর, রসিদ, SMS আর কার্ড নম্বরের বাস্তব উদাহরণে
  • string তুলনা: ===, < আর localeCompare — বাংলা নাম সাজানোর সঠিক উপায়
  • বাংলা Unicode গভীরে: code unit, code point আর grapheme; normalization; বাংলা অঙ্ক রূপান্তর

১ · string আসলে কী — ১৬-বিটের টুকরোর এমন সারি, যা বদলায় না

বাস্তব প্রোগ্রামের বড় অংশই লেখা। গ্রাহকের নাম, মোবাইল নম্বর, ঠিকানা, bKash-এর SMS, পণ্যের নাম, ওয়েবসাইটের URL — সবই লেখা। JavaScript-এ লেখা রাখার টাইপ হলো string. পর্ব ০৬-এ দেখেছেন, string একটি primitive টাইপ। এবার ভেতরে ঢুকে দেখা যাক।

প্রথমে একটি মানসিক মডেল। একটি string হলো ছোট ছোট ঘরের একটি সারি — ট্রেনের বগির মতো। প্রতিটি ঘরে একটি সংখ্যা থাকে, আর ঘরগুলোর নম্বর শুরু হয় 0 থেকে। কিন্তু একটি গুরুত্বপূর্ণ কথা: প্রতিটি ঘর একটি “অক্ষর” নয়, বরং ১৬ বিটের একটি টুকরো — যার নাম code unit. String-কে এভাবে টুকরোয় সাজানোর নিয়মের নাম UTF-16.

মূল ধারণা

JavaScript-এর string হলো UTF-16 code unit-এর একটি অপরিবর্তনীয় (immutable) ক্রম। এই এক বাক্য থেকেই পুরো পর্বের সব আচরণ বেরিয়ে আসে: .length গোনে code unit, s[i] দেয় একটি code unit, আর কোনো method পুরোনো string বদলায় না — নতুন string বানায়।

ইংরেজি অক্ষরের জন্য একটি code unit = একটি অক্ষর, তাই ইংরেজিতে কাজ করলে পার্থক্যটা চোখেই পড়ে না: "Hello".length হলো 5. কিন্তু "বাংলা" লিখলে? চোখে আপনি দেখছেন দুটি গুচ্ছ — “বাং” আর “লা”। ভেতরে আছে পাঁচটি টুকরো: ব, া-কার, ং, ল, া-কার। তাই "বাংলা".length হলো 5. আর একটি 🙂 ইমোজি একাই 2. কেন — সেটি অংশ ৬-এ ধাপে ধাপে দেখবেন।

string লেখার তিন উপায়

লেখার ধরনউদাহরণকখন সুবিধা
একক উদ্ধৃতি '...''Dhaka'সাধারণ লেখা; ভেতরে " রাখা সহজ
দ্বৈত উদ্ধৃতি "...""It's 5 o'clock"ভেতরে ' থাকলে; JSON-এ শুধু এটিই চলে
Backtick `...``মোট ${total} টাকা`মান বসানো, একাধিক লাইন — template literal

'...' আর "..."-এর মধ্যে আচরণে কোনো পার্থক্য নেই — একটি প্রজেক্টে একটি বেছে নিয়ে সবখানে সেটি রাখুন (Prettier-এর মতো টুল নিজেই এক রকম করে দেয়, পর্ব ৪৬)। ভেতরে একই উদ্ধৃতিচিহ্ন বা বিশেষ অক্ষর লাগলে লিখতে হয় escape sequence — backslash \ দিয়ে শুরু হওয়া ছোট সংকেত:

লিখবেনমানেউদাহরণ
\nনতুন লাইন"নাম\nঠিকানা" → দুই লাইনে
\ttab (কলাম সরানো)"চা\t20"
\' \" \\উদ্ধৃতিচিহ্ন বা backslash নিজেই'It\'s', "C:\\new"
\u0985ঠিক ৪ hex অঙ্কের একটি code unit"\u0985" → অ
\u{1F600}যেকোনো code point (ES2015 থেকে)"\u{1F600}" → 😀

Windows-এর path লিখতে গিয়ে "C:\new\table" লিখলে ভেতরে \n হয়ে যায় নতুন লাইন, আর \t হয়ে যায় tab — path ভেঙে যায়, কোনো error ছাড়াই। হয় \\ লিখুন, নয়তো String.raw ব্যবহার করুন (অংশ ২)।

Immutability — string কখনো বদলায় না

নিচের ডায়াগ্রামে দেখুন, toUpperCase() ডাকলে আসলে কী ঘটে, আর city[0] = "D" লিখলে কেন কিছুই বদলায় না। মনে রাখবেন: variable হলো নামফলক, string হলো মেমরিতে থাকা মান (পর্ব ০৭-এ binding-এর ধারণা দেখেছেন)।

1let city = "dhaka";2const upper = city.toUpperCase();3city[0] = "D";        // কিছুই হবে না4console.log(city, upper);5city = "Dhaka";       // নামফলক সরানো6console.log(city);
নামফলক আর মেমরির string
city →
upper →
মেমরিতে
  1. একটি string, একটি নামফলক। মেমরিতে তৈরি হলো "dhaka", আর city নামফলকটি তার দিকে তাকিয়ে আছে।
  2. toUpperCase — নতুন string. Method-টি "dhaka" পড়ে সম্পূর্ণ নতুন একটি string "DHAKA" বানাল এবং ফেরত দিল। upper তাকাল নতুনটির দিকে। মূল "dhaka"-তে একটি অক্ষরও বদলায়নি।
  3. ঘরে লেখার চেষ্টা — উপেক্ষিত। city[0] = "D" দেখতে array-তে লেখার মতো, কিন্তু string-এর ঘর শুধু পড়া যায়। সাধারণ (sloppy) mode-এ এটি চুপচাপ উপেক্ষিত হয় — কোনো error নেই, কোনো পরিবর্তনও নেই। Strict mode-এ (module আর class-এ যা নিজে থেকেই চালু) এখানে TypeError আসে।
  4. প্রমাণ। ছাপা হলো dhaka DHAKA — মূলটি অক্ষত, নতুনটি আলাদা।
  5. Variable বদলানো ≠ string বদলানো। city = "Dhaka" আরেকটি নতুন string বানিয়ে নামফলকটি সেদিকে সরিয়ে দিল। পুরোনো "dhaka"-কে এখন আর কেউ দেখে না, তাই garbage collector পরে সেটি সরিয়ে দেবে (পর্ব ০৫)। let দিয়ে নামফলক সরানো যায়; string নিজে কখনো বদলায় না।
  6. শেষ ফল। ছাপা হলো Dhaka. “string বদলালাম” মনে হলেও আসলে প্রতিবার নতুন string তৈরি হয়ে variable তার দিকে সরেছে।

Strict mode-এ ঠিক কী বার্তা আসে? Chrome, Edge আর Node.js-এ (তিনটিই V8) এটি:

TypeError: Cannot assign to read only property '0' of string 'dhaka'

বাংলাদেশ ব্যাংকের ছাপানো একটি নোট ভাবুন। নোটের ওপর কলম দিয়ে “৫০০” কেটে “১০০০” লিখলে সেটি ১০০০ টাকার নোট হয়ে যায় না। নতুন মূল্যের নোট চাইলে নতুন নোট ছাপাতে হয়। string-ও তেমন — একবার তৈরি হলে ছাপানো নোটের মতো স্থির; “বদলানো” মানে সবসময় নতুন string ছাপানো।

২ · Template literal — backtick দিয়ে লেখা গড়া

ES2015 (ES6)-এর আগে একটি SMS বার্তা বানাতে লিখতে হতো এমন জোড়াতালি: "প্রিয় " + name + ", আপনার " + amount + " টাকা জমা হয়েছে।" — একটি space বাদ পড়লেই বার্তা বিশ্রী। Template literal এই ঝামেলা মিটিয়েছে। Backtick (`, কি-বোর্ডে সাধারণত Esc-এর নিচে) দিয়ে লেখা string-এর ভেতরে ${...} লিখলে সেখানে যেকোনো expression-এর মান বসে যায় — একে বলে interpolation.

const name = "রাফি", amount = 1250;

// পুরোনো পথ
const a = "প্রিয় " + name + ", আপনার " + amount + " টাকা জমা হয়েছে।";

// template literal
const b = `প্রিয় ${name}, আপনার ${amount} টাকা জমা হয়েছে।`;

// একাধিক লাইন — \n লাগে না
const receipt = `ABCL Store
পণ্য: ${3} টি
মোট: ${3 * 25} টাকা`;

এই কোডটি দেখানোর জন্য; চালানোর মতো উদাহরণ আসছে অংশ ৪-এ। a আর b হুবহু একই string. কিন্তু b পড়লেই বোঝা যায় বার্তাটি দেখতে কেমন হবে। receipt-এ লাইন ভাঙা সরাসরি string-এর অংশ হয়ে গেছে। সাবধান: backtick-এর ভেতরের সামনের space-ও string-এর অংশ — কোড সুন্দর দেখাতে লাইনের শুরুতে indent দিলে সেই space-ও বার্তায় চলে যাবে।

${...}-এর ভেতরে শুধু variable নয়, যেকোনো expression বসে — হিসাব, function কল, ternary. JavaScript সেগুলো বাম থেকে ডানে একে একে হিসাব করে, প্রতিটি ফলকে string-এ রূপান্তর করে, তারপর সব টুকরো জুড়ে একটি নতুন string বানায়। ধাপে ধাপে দেখুন:

${qty} " × " ${price} " = " ${qty * price}
ধরা যাক: const qty = 3, price = 25;
এই টুকরো
এখন পর্যন্ত
  1. প্রথম ফাঁকা ঘর। Engine প্রথম ${qty} দেখে ভেতরের expression হিসাব করল — মান 3, একটি number. তারপর সেটিকে string "3"-এ রূপান্তর করল (ভেতরে String(3)-এর মতো)।
  2. স্থির লেখা। দুই ${}-এর মাঝের লেখা " × " যেমন আছে তেমনই বসে — space-সহ।
  3. দ্বিতীয় ফাঁকা ঘর। price → 25 → "25".
  4. আবার স্থির লেখা। " = " বসল।
  5. পুরো expression. ${qty * price}-এর ভেতরে গুণ আগে হয় — 75, তারপর "75". লক্ষ করুন, এখানে + নেই, তাই পর্ব ০৬-এর “"5" + 2 = "52"” ধরনের coercion-ফাঁদ নেই — হিসাব হয় number-এ, string-এ রূপান্তর হয় শেষে।
  6. নতুন string তৈরি। সব টুকরো জুড়ে তৈরি হলো "3 × 25 = 75". পরে qty বদলালেও এই string বদলাবে না — template literal লেখার মুহূর্তেই একবার হিসাব হয়ে যায়।

Tagged template আর String.raw — সংক্ষেপে

Backtick-এর ঠিক আগে একটি function-এর নাম লিখলে (tag`...`) সেটি হয় tagged template: JavaScript নিজে string জোড়া না দিয়ে স্থির টুকরোগুলোর array আর ${}-এর মানগুলো সেই function-এর হাতে তুলে দেয়। function ঠিক করে কী ফেরত দেবে। লাইব্রেরিগুলো এটি দিয়ে নিরাপদে HTML বা SQL বানায় — মানগুলো আলাদা হাতে আসে বলে সেগুলো escape করা সহজ।

ভাষার সাথেই আসে একটি tag: String.raw. এটি escape sequence-কে escape হিসেবে না ধরে হুবহু রেখে দেয়: String.raw`C:\new\table` দেয় C:\new\table — ঠিক যেমন লিখেছেন। `a\tb`.length হলো 3 (মাঝে একটি tab), কিন্তু String.raw`a\tb`.length হলো 4. Regex লেখার সময় এটি খুব কাজে লাগে, পর্ব ৩০-এ দেখবেন।

৩ · অংশ বের করা — index, at(), slice আর substring

একটি string-এর যেকোনো ঘর পড়া যায় index দিয়ে: "Dhaka"[0] হলো "D". শেষ ঘর চাইলে আগে লিখতে হতো s[s.length - 1]. ES2022-এ যুক্ত at() এটি সহজ করেছে: "Dhaka".at(-1) দেয় "a". ঋণাত্মক index মানে “শেষ থেকে গোনা”। সাবধান: "Dhaka"[-1] কিন্তু undefined — বন্ধনীর ভেতরে ঋণাত্মক সংখ্যা চলে না, শুধু at()-এ চলে।

একাধিক ঘর একসাথে কাটতে slice(start, end). নিয়ম দুটি: start-এর ঘর থাকে, end-এর ঘর বাদ যায়; আর ঋণাত্মক সংখ্যা মানে length থেকে বিয়োগ। "Bangladesh"-এর ঘরগুলো এমন:

  B   a   n   g   l   a   d   e   s   h
  0   1   2   3   4   5   6   7   8   9
-10  -9  -8  -7  -6  -5  -4  -3  -2  -1
const s = "Bangladesh"; // length = 10
কল
start
end
ফল
  1. সাধারণ কাটা। ঘর 0 থেকে ঘর 5 পর্যন্ত; ঘর 6 বাদ। ফল "Bangla". একটি সহজ হিসাব: ফলের দৈর্ঘ্য = end − start = 6.
  2. শেষ থেকে চারটি। -4 মানে 10 − 4 = 6. end না দিলে শেষ পর্যন্ত। ফাইলের extension বা নম্বরের শেষ অঙ্কগুলো নেওয়ার জন্য এটাই সবচেয়ে প্রচলিত রূপ।
  3. দুই দিক থেকে ছাঁটা। শুরু থেকে ২টি আর শেষ থেকে ৪টি বাদ — ঘর 2 থেকে 5, ফল "ngla".
  4. দুটোই ঋণাত্মক। 6 থেকে 8 — "des". শেষের h বাদ, কারণ end-এর ঘর সবসময় বাদ।
  5. উল্টো ক্রম — slice খালি দেয়। start যদি end-এর পরে হয়, slice কোনো error দেয় না, শুধু খালি string "" ফেরত দেয়।
  6. substring অদলবদল করে। substring দেখতে একই রকম, কিন্তু start বড় হলে দুটি সংখ্যা চুপচাপ অদলবদল করে নেয় — ফল "ngla". এমন “চুপচাপ সাহায্য” বাগ লুকিয়ে রাখতে পারে।
  7. substring ঋণাত্মক বোঝে না। substring(-4)-এ ঋণাত্মক সংখ্যাকে 0 ধরা হয় — ফল পুরো string! যে AI বা মানুষ slice ভেবে substring লেখেন, তাঁর কোড এখানেই ভুল করে। নিয়ম মনে রাখুন: আধুনিক কোডে slice ব্যবহার করুন।
  8. একটি ঘর: at() বনাম [ ]. s.at(-1) দেয় "h"; কিন্তু s[-1] দেয় undefined — বন্ধনী ঋণাত্মক সংখ্যাকে index হিসেবে বোঝে না, “-1” নামের একটি property খোঁজে, পায় না।

substr কেন এড়াবেন? পুরোনো কোডে দেখবেন s.substr(start, length) — দ্বিতীয় সংখ্যাটি end নয়, দৈর্ঘ্য। এটি ECMAScript মানদণ্ডের Annex B-তে রাখা আছে — অর্থাৎ পুরোনো ওয়েবসাইট যাতে না ভাঙে সেজন্য ব্রাউজার রাখে, নতুন কোডে ব্যবহারের জন্য নয়। MDN-ও একে deprecated বলে চিহ্নিত করেছে। AI প্রায়ই পুরোনো কোড থেকে শিখে substr লেখে; সেখানে slice বসান।

৪ · খোঁজা, বদলানো, পরিষ্কার করা — রোজকার method-এর বাক্স

string-এর সাথে আসে অনেকগুলো built-in method. সবগুলো মুখস্থ করার দরকার নেই — কোন কাজের জন্য কোনটি আছে, সেটুকু জানলেই চলে। মনে রাখুন, এদের কোনোটিই মূল string বদলায় না; ফল চাইলে নতুন variable-এ রাখুন।

কাজMethodউদাহরণ → ফল
কোথায় আছেindexOf, lastIndexOf"banana".indexOf("an") → 1; lastIndexOf("an") → 3; না পেলে → -1
আছে কি নাincludes, startsWith, endsWith"file.pdf".endsWith(".pdf") → true
বড়/ছোট হাতেরtoUpperCase, toLowerCase"dhaka".toUpperCase() → "DHAKA"; বাংলায় কোনো প্রভাব নেই
দুই পাশের ফাঁকা বাদtrim, trimStart, trimEnd" hi \n".trim() → "hi"
নির্দিষ্ট দৈর্ঘ্যে ভরাটpadStart, padEnd"7".padStart(3, "0") → "007"
পুনরাবৃত্তিrepeat"-".repeat(5) → "-----"
ভাগ ও জোড়াsplit, (array-এর) join"a-b-c".split("-") → ["a","b","c"]
বদলে দেওয়াreplace, replaceAll"a-b-c".replace("-", "+") → "a+b-c"; replaceAll → "a+b+c"

কয়েকটি সূক্ষ্ম বিষয়, যেগুলো নিয়ে ভুল সবচেয়ে বেশি হয়:

  • replace শুধু প্রথমটি বদলায়। string দিয়ে খুঁজলে replace কেবল প্রথম মিলটি বদলায়। সবগুলো বদলাতে ES2021-এ যুক্ত replaceAll. আগে এর জন্য regex-এর /g flag বা split(x).join(y) কৌশল লাগত — regex নিয়ে বিস্তারিত পর্ব ৩০-এ।
  • toLocaleUpperCase. কিছু ভাষায় বড় হাতের নিয়ম আলাদা। তুর্কি ভাষায় "i".toLocaleUpperCase("tr") দেয় ফোঁটাওয়ালা "İ", সাধারণ toUpperCase দেয় "I". আবার জার্মান "ß".toUpperCase() হয় "SS" — দৈর্ঘ্যই বদলে যায়! বাংলা লিপিতে বড়-ছোট হাতের ধারণা নেই, তাই এগুলো বাংলা লেখাকে ছোঁয় না।
  • +, concat আর template literal. তিনটিই নতুন string বানায়। "a".concat("b", 1) → "ab1". কিন্তু +-এর ফাঁদ মনে রাখুন: "x" + 1 + 2 হলো "x12", আর 1 + 2 + "x" হলো "3x" — বাম থেকে ডানে হিসাব হয়। লেখা গড়তে template literal সবচেয়ে পরিষ্কার।
  • String(x) বনাম x.toString(). দুটোই string বানায়, কিন্তু null বা undefined-এ পার্থক্য: String(null) দেয় "null", আর null.toString() দেয় TypeError: Cannot read properties of null (reading 'toString') (V8-এর বার্তা)। মান কী হবে নিশ্চিত না হলে String() নিরাপদ।

এবার চারটি বাস্তব কাজ একসাথে — ফোন নম্বর পরিষ্কার, SMS বার্তা, রসিদের লাইন আর কার্ড নম্বর ঢাকা। চালিয়ে দেখুন:

// ১) ফর্মে টাইপ করা ফোন নম্বর পরিষ্কার করা
const raw = "  +880 1712-345678 ";
const phone = raw.trim().replaceAll(" ", "").replaceAll("-", "");
console.log(phone, phone.startsWith("+880"), phone.length);

// ২) template literal দিয়ে SMS বার্তা
const name = "রাফি", amount = 1250;
console.log(`প্রিয় ${name}, আপনার ${amount} টাকা জমা হয়েছে।`);

// ৩) রসিদের লাইন — padEnd আর padStart
const items = [["Tea", 20], ["Singara", 15], ["চা", 20]];
for (const [item, price] of items) {
  console.log(item.padEnd(10, ".") + String(price).padStart(5));
}

// ৪) কার্ড নম্বরের শেষ ৪টি অঙ্ক ছাড়া সব ঢাকা
const card = "4111 1111 1111 1234".replaceAll(" ", "");
console.log(card.slice(-4).padStart(card.length, "*"));

console.log(JSON.stringify(raw));   // মূল string অক্ষত

প্রথম লাইনে আসে +8801712345678 true 14: trim দুই পাশের space ছাঁটল, দুটি replaceAll মাঝের space আর হাইফেন সরাল — প্রতিটি ধাপে নতুন string, পরের method সেটির ওপর চলল (একে বলে method chaining). দ্বিতীয় লাইনে বার্তা। রসিদে Tea....... আর Singara... দুটোই ১০ ঘর, সংখ্যাগুলো ডানে ৫ ঘরে সাজানো। কিন্তু চা........-তে খেয়াল করুন: "চা" ২টি code unit, তাই ৮টি ফোঁটা বসেছে — অথচ পর্দায় বাংলা অক্ষরের চওড়া ইংরেজির মতো নয়, আর যুক্তাক্ষরে code unit বেশি কিন্তু চওড়া কম। ফলে বাংলা রসিদে padEnd-এর কলাম চোখে সোজা দেখায় না। padEnd গোনে code unit, পর্দার চওড়া নয়। কার্ডের লাইনে আসে ************1234, আর শেষ লাইন প্রমাণ করে raw একটুও বদলায়নি।

কার্ড নম্বর ঢাকা শুধু দেখানোর জন্য। আসল কার্ড নম্বর ব্রাউজারের JavaScript-এ বা নিজের ডেটাবেসে সাধারণ লেখা হিসেবে রাখা উচিত নয় — পেমেন্টের তথ্য সামলায় নির্ভরযোগ্য পেমেন্ট গেটওয়ে। নিরাপদ JavaScript নিয়ে বিস্তারিত পর্ব ৪৩-এ।

৫ · string তুলনা — ===, < আর localeCompare

=== দুটি string-এর প্রতিটি code unit হুবহু মেলায়। একটি space বেশি, একটি অক্ষর বড় হাতের, বা — বাংলায় যা খুব সাধারণ — একই দেখতে কিন্তু ভিন্ন code point (অংশ ৭) হলেই false.

< আর > string-এর ক্ষেত্রে code unit-এর সংখ্যা ধরে তুলনা করে — অভিধানের নিয়মে নয়। ইংরেজিতে এর প্রথম ফাঁদ: সব বড় হাতের অক্ষরের সংখ্যা ছোট হাতের আগে। তাই "Zara" < "anik" হলো true, আর ["rahim", "Karim", "anik", "Zara"].sort() দেয় ["Karim", "Zara", "anik", "rahim"] — কোনো মানুষ এভাবে নাম সাজায় না।

বাংলায় Unicode-এর ঘরগুলো মোটামুটি বর্ণমালার ক্রমে সাজানো (অ, আ, ই… ক, খ…), তাই সাধারণ শব্দে < প্রায়ই ঠিক ফল দেয় — আর সেজন্যই বিপদটা চোখে পড়ে না। কিন্তু কয়েকটি অক্ষরের ঘর বর্ণমালার জায়গায় নেই। আমরা Node.js 20 আর Edge-এ চালিয়ে দেখেছি:

শব্দগুলো.sort() (code unit)localeCompare(b, "bn")
ড়া (একক \u09DC দিয়ে), ঢাকা, হাতি, ডালডাল, ঢাকা, হাতি, ড়া — ড় চলে গেছে হ-এর পরে, কারণ U+09DC সংখ্যাটি হ (U+09B9)-এর চেয়ে বড়ড়া, ডাল, ঢাকা, হাতি — ড়-কে ড-এর পাশে রেখেছে
রহিম, করিম, আনিকা, ঋতু, ং, ৎং, আনিকা, ঋতু, করিম, রহিম, ৎআনিকা, ঋতু, ং, করিম, ৎ, রহিম

মানুষের ভাষার নিয়মে সাজাতে ব্যবহার করুন a.localeCompare(b, "bn") — এটি a আগে হলে ঋণাত্মক, পরে হলে ধনাত্মক, সমান হলে 0 দেয়, তাই সরাসরি sort-এ বসে: names.sort((a, b) => a.localeCompare(b, "bn")). অনেক বড় তালিকা হলে একবার const coll = new Intl.Collator("bn") বানিয়ে names.sort(coll.compare) লিখুন — প্রতিবার নতুন করে নিয়ম লোড করতে হয় না। ইংরেজি নামেও localeCompare দেয় ["anik", "Karim", "rahim", "Zara"]. বাংলা কোন অক্ষর ঠিক কোথায় বসবে, তা নির্ভর করে ব্রাউজারের ভেতরের ভাষা-তথ্যের (CLDR/ICU) ওপর — তাই খুব সূক্ষ্ম ক্ষেত্রে ব্রাউজারভেদে ফল সামান্য আলাদা হতে পারে। উপরের ফলগুলো আমাদের যাচাই করা দুই পরিবেশের।

মূল ধারণা

=== = হুবহু একই code unit কি না (মেশিনের প্রশ্ন)। < = কোন code unit-এর সংখ্যা ছোট (মেশিনের ক্রম)। localeCompare / Intl.Collator = কোন ভাষায় মানুষ কোনটি আগে লেখে (মানুষের ক্রম)। মানুষকে দেখানোর তালিকা সাজাতে সবসময় তৃতীয়টি।

৬ · বাংলা Unicode গভীরে — code unit, code point আর grapheme

এবার এই পর্বের সবচেয়ে গুরুত্বপূর্ণ অংশ। “একটি অক্ষর” বলতে আসলে তিনটি আলাদা জিনিস বোঝানো যায়, আর JavaScript-এর আলাদা টুল আলাদা জিনিস গোনে।

🧱 Code unit

UTF-16-এর ১৬-বিটের একটি টুকরো। .length, s[i], slice, at — সবাই এটা গোনে।

"বাংলা🙂" → ৭টি

🔢 Code point

Unicode-এর দেওয়া একটি নম্বর, যেমন অ = U+0985. [...s], for…of, codePointAt এটা গোনে।

"বাংলা🙂" → ৬টি

👁️ Grapheme

মানুষের চোখে একটি “অক্ষর”-এর একক — কার, ফলা, চিহ্নসহ। Intl.Segmenter এটা গোনে।

"বাংলা🙂" → ৩টি
const text = "বাংলা🙂";
স্তর
টুকরো
সংখ্যা
কে গোনে
  1. চোখের দেখা। পর্দায় তিনটি একক: “বাং”, “লা” আর একটি হাসিমুখ। SMS লেখার সময় মানুষ এভাবেই ভাবেন।
  2. UTF-16 code unit — ৭টি। বাংলার প্রতিটি অক্ষর-চিহ্ন (ব, া-কার, ং, ল, া-কার) একটি করে ১৬-বিটের ঘরে আঁটে — বাংলা Unicode-এর U+0980–U+09FF অংশে। কিন্তু 🙂-এর নম্বর U+1F642, যা ১৬ বিটে (সর্বোচ্চ U+FFFF) আঁটে না। তাই সেটি ভেঙে দুটি ঘরে রাখা হয়: D83D আর DE42 — এই জোড়ার নাম surrogate pair. .length এই ঘরগুলোই গোনে: 7.
  3. Code point — ৬টি। Code point হলো Unicode-এর দেওয়া আসল নম্বর। surrogate pair-এর দুটি ঘর মিলে একটি code point U+1F642. ES2015 থেকে string-এর iterator code point ধরে চলে, তাই [...text] আর for…of দেয় ৬টি টুকরো।
  4. Grapheme — ৩টি। “বাং” আসলে তিনটি code point (ব + া + ং), কিন্তু চোখে একটি একক। Unicode-এর নিয়মে (UAX #29) এমন গুচ্ছের নাম grapheme cluster. Intl.Segmenter এই নিয়ম মেনে ভাগ করে: বাং · লা · 🙂.
  5. প্রশ্ন অনুযায়ী টুল। মেমরি বা API-র সীমা (যেমন SMS gateway, যা ১৬-বিট ঘর গোনে) → .length. Unicode নম্বর নিয়ে কাজ → code point. মানুষকে “আর ১০টি অক্ষর বাকি” দেখানো, কার্সর সরানো, উল্টানো → grapheme. কোনো একটি “সঠিক দৈর্ঘ্য” নেই — আছে সঠিক প্রশ্ন।

Intl.Segmenter — চোখের অক্ষর গোনা

ES2022-এর Intl অংশে (ECMA-402) যুক্ত Intl.Segmenter লেখাকে grapheme, শব্দ বা বাক্যে ভাগ করে। Chrome-এ এটি ২০২০ সাল থেকে আছে; Firefox-এ এসেছে অনেক পরে, ২০২৪ সালে। চালিয়ে দেখুন — সাথে উল্টানো, ইমোজি আর normalization-এর পরীক্ষাও আছে:

const seg = new Intl.Segmenter("bn", { granularity: "grapheme" });
const graphemes = s => [...seg.segment(s)].map(x => x.segment);

const text = "বাংলা🙂";
console.log("code unit:", text.length);
console.log("code point:", [...text].length);
console.log("grapheme:", graphemes(text).length, graphemes(text));
console.log("ক্ষমা:", "ক্ষমা".length, graphemes("ক্ষমা"));

// উল্টানো: ভুল পথ বনাম grapheme ধরে
console.log("split(''):", "বাংলা".split("").reverse().join(""));
console.log("grapheme:", graphemes("বাংলা").reverse().join(""));

// ইমোজি = surrogate pair
const smile = "🙂";
console.log(smile.length, smile.codePointAt(0).toString(16), String.fromCodePoint(0x1F642));

// একই দেখতে, ভিন্ন code point
const y1 = "\u09DF", y2 = "\u09AF\u09BC";   // য় একক বনাম য + ়
console.log(y1 === y2, y1.normalize("NFC") === y2.normalize("NFC"));

প্রথম তিন লাইনে 7, 6 আর 3 [ 'বাং', 'লা', '🙂' ] — ডায়াগ্রামের সাথে হুবহু মিল। "ক্ষমা"-র দৈর্ঘ্য 5 (ক + ্ + ষ + ম + া), আর grapheme ভাগ [ 'ক্ষ', 'মা' ]. এই সংখ্যাটি engine-এর Unicode version-এর ওপর নির্ভর করে। ২০২৩ সালের Unicode 15.1 ভারতীয় লিপির যুক্তাক্ষর (হসন্ত দিয়ে জোড়া) এক গুচ্ছ ধরার নিয়ম যোগ করেছে; আমাদের যাচাই করা Node.js 20.17 (ICU 75, Unicode 15.1) আর বর্তমান Edge — দুটোতেই ফল ২টি গুচ্ছ। পুরোনো নিয়মের পরিবেশে একই শব্দ ৩টি গুচ্ছে ভাঙে (ক্‌ · ষ · মা)। split('') দিয়ে উল্টালে আসে ালংাব — কার-চিহ্ন আগে চলে গিয়ে অর্থহীন লেখা; grapheme ধরে উল্টালে আসে লাবাং. ইমোজির লাইনে 2 1f642 🙂: দুটি code unit, একটি code point. শেষ লাইনে false true — পরের অংশে এর ব্যাখ্যা।

Surrogate pair-এর মাঝখানে কাটলে লেখা নষ্ট হয়: "🙂x".slice(0, 1) দেয় একটি অর্ধেক ইমোজি (lone surrogate), যা পর্দায় সাধারণত � হয়ে দেখায় এবং কোনো সার্ভারে পাঠালে encoding-এর গোলমাল বাধাতে পারে। লম্বা বার্তা কেটে “…” লাগানোর সময় তাই slice নয়, grapheme ধরে কাটুন। ES2024-এ এমন অর্ধেক টুকরো খোঁজার জন্য isWellFormed() আর সারানোর জন্য toWellFormed() যুক্ত হয়েছে।

বাস্তব উদাহরণ: বাংলা SMS-এর অক্ষর-সীমা

মোবাইলের SMS-এ ইংরেজি (GSM 7-bit) বার্তায় একবারে ১৬০ অক্ষর যায়, কিন্তু বাংলা থাকলে বার্তা যায় ১৬-বিটের UCS-2 encoding-এ — একবারে ৭০টি ঘর, আর লম্বা বার্তা ভাগ হলে প্রতি ভাগে ৬৭টি। এখানে মজার ব্যাপার: SMS gateway গোনে ১৬-বিটের ঘর — অর্থাৎ ঠিক .length-এর মতো! তাই “কয়টি SMS লাগবে” হিসাবের জন্য .length সঠিক টুল (একটি ইমোজি ২টি ঘর খায়)। কিন্তু ব্যবহারকারীকে “আর কত অক্ষর লিখতে পারবেন” দেখাতে চাইলে মানুষ ভাবেন grapheme-এ। ভালো ফর্ম দুটোই দেখায়: “১২টি অক্ষর · ৩১/৭০ ঘর”।

codePointAt আর fromCodePoint

"অ".codePointAt(0) দেয় 2437 (hex-এ 985, অর্থাৎ U+0985); উল্টো দিকে String.fromCodePoint(0x1F642) দেয় 🙂. পুরোনো charCodeAt আর String.fromCharCode কাজ করে code unit নিয়ে: "😀".charCodeAt(0) দেয় শুধু প্রথম অর্ধেক, 55357. ES2015-এ code point-ভিত্তিক নতুন method দুটি যুক্ত হয়েছে ঠিক এই সমস্যা মেটাতে।

৭ · Normalization আর বাংলা অঙ্ক

আগের কোডের শেষ লাইনে y1 === y2 ছিল false, অথচ দুটোই পর্দায় হুবহু “য়”। কারণ Unicode-এ য় লেখার দুটি উপায় আছে: একটি একক code point U+09DF, আর য (U+09AF) + নুক্তা ় (U+09BC). বাংলায় ড় (U+09DC) আর ঢ় (U+09DD)-এরও একই অবস্থা। কি-বোর্ড, ফোন বা পুরোনো কনভার্টার ভেদে আলাদা রূপ আসতে পারে। ফলে ডেটাবেসে খুঁজলে “ময়না” পাওয়া যায় না, যদিও সেটি আছে!

সমাধান normalization: s.normalize("NFC") একই অর্থের সব রূপকে একটি নির্দিষ্ট রূপে আনে। তুলনা, খোঁজা বা ডেটাবেসে রাখার আগে ব্যবহারকারীর লেখা normalize করে নিন।

বাংলার একটি অদ্ভুত ব্যতিক্রম: সাধারণত NFC মানে “যতটা সম্ভব জোড়া দেওয়া”। কিন্তু য়, ড়, ঢ় Unicode-এর “composition exclusion” তালিকায় আছে — তাই NFC এদের জোড়া দেয় না, উল্টো ভেঙে দুই code point-এ (য + ়) রাখে। আমরা Node.js 20 আর Edge-এ যাচাই করেছি: "\u09DF".normalize("NFC")-এর code point হলো 9AF আর 9BC. অন্যদিকে ো (U+09CB) আর ে + া দুটোই NFC-তে একক U+09CB হয়। নিয়মটা মনে রাখার দরকার নেই; মনে রাখুন — দুই দিকেই একই normalize চালালে তুলনা ঠিক হয়।

বাংলা অঙ্ক ↔ ইংরেজি অঙ্ক

বাংলাদেশি ফর্মে মানুষ প্রায়ই বাংলা অঙ্কে ফোন নম্বর লেখেন: “০১৭১২৩৪৫৬৭৮”। কিন্তু JavaScript বাংলা অঙ্ক সংখ্যা হিসেবে চেনে না — Number("১২৩") দেয় NaN. বাংলা অঙ্ক ০–৯ Unicode-এ পরপর সাজানো (U+09E6 থেকে U+09EF), তাই একটি ছোট সাহায্যকারী function যথেষ্ট:

const BN = "০১২৩৪৫৬৭৮৯";
const toLatin = s => s.replace(/[০-৯]/g, d => BN.indexOf(d));
const toBangla = s => String(s).replace(/[0-9]/g, d => BN[d]);

toLatin("০১৭১২৩৪৫৬৭৮");   // "01712345678"
toBangla(2026);            // "২০২৬"

// দেখানোর জন্য Intl (পর্ব ২০-এ বিস্তারিত)
new Intl.NumberFormat("bn-BD").format(1234567);   // "১২,৩৪,৫৬৭"

এখানে replace-এর প্রথম argument একটি ছোট regex — /[০-৯]/g মানে “যেকোনো বাংলা অঙ্ক, সবগুলো”; regex-এর পুরো গল্প পর্ব ৩০-এ। দ্বিতীয় argument একটি function, যা প্রতিটি মিলের জন্য বদলি মান ফেরত দেয়: BN.indexOf("৭") হলো 7. মন্তব্যে লেখা ফলগুলো আমরা Node.js 20 আর Edge-এ যাচাই করেছি। শেষ লাইনে লক্ষ করুন, bn-BD নিয়মে হাজার-লাখের কমা বসে (১২,৩৪,৫৬৭) — দেখানোর কাজে হাতে লেখা function-এর চেয়ে Intl ভালো; তারিখ আর টাকার ফরম্যাট পর্ব ২০-এ।

C/C++/Python/Java জানলে

Java-র সাথে মিল সবচেয়ে বেশি: Java-র String-ও immutable UTF-16, length() গোনে code unit, আর ইমোজিতে একই surrogate pair-এর সমস্যা (Java-র String পর্ব)। Python 3-এর str গোনে code point, তাই len("🙂") হলো 1, কিন্তু len("বাংলা") সেখানেও 5 — grapheme সমস্যা সব ভাষায় একই (Python-এর string পর্ব)। C/C++-এ string মূলত বাইটের সারি, আর strlen গোনে UTF-8 বাইট — সেখানে “বাংলা” ১৫ বাইট। JavaScript-এ আলাদা char টাইপ নেই; একটি অক্ষরও এক ঘরের string.

পরীক্ষা ও ইন্টারভিউতে
  • JavaScript-এ string কি mutable? — না, immutable. প্রতিটি method নতুন string দেয়; s[0] = "x" sloppy mode-এ উপেক্ষিত, strict mode-এ TypeError.
  • "😀".length কত, কেন? — 2, কারণ U+1F600 ১৬ বিটে আঁটে না, তাই UTF-16-এ surrogate pair (দুটি code unit) লাগে।
  • slice আর substring-এর পার্থক্য? — slice ঋণাত্মক index শেষ থেকে গোনে; substring ঋণাত্মককে 0 ধরে আর start > end হলে অদলবদল করে।
  • replace আর replaceAll? — string দিয়ে খুঁজলে replace শুধু প্রথম মিল বদলায়; replaceAll (ES2021) সবগুলো।
  • কোনো string উল্টানোর সঠিক উপায়? — split('') code unit ভাঙে, বাংলা ও ইমোজি নষ্ট করে; Intl.Segmenter দিয়ে grapheme-এ ভাগ করে উল্টান।

৮ · AI-এর যুগে — বাংলা লেখায় AI-এর চেনা ভুল

AI মডেলগুলো যে বিপুল কোড থেকে শিখেছে, তার প্রায় সবটাই ইংরেজি লেখা নিয়ে। ইংরেজিতে code unit = অক্ষর, তাই সেখানে যে কোড নিখুঁত, বাংলায় সেটিই চুপচাপ ভুল করে। কোনো error আসে না — শুধু ভুল সংখ্যা, ভাঙা লেখা, ভুল ক্রম।

AI যা লেখেবাংলায় কী হয়যা লিখতে বলবেন
“অক্ষর গুনতে s.length”“বাংলা” = 5, 🙂 = 2 — চোখের হিসাবের সাথে মেলে নামানুষের জন্য Intl.Segmenter; SMS ঘরের জন্য .length — কোনটি দরকার, স্পষ্ট করুন
s.split('').reverse().join('')কার-চিহ্ন সরে যায়, ইমোজি ভেঙে �grapheme-এ ভাগ করে উল্টানো
s.substr(0, 10) + "…"legacy method; তার ওপর ইমোজি বা যুক্তাক্ষরের মাঝখানে কাটতে পারেgrapheme ধরে প্রথম ১০টি নিয়ে জোড়া
names.sort() বা a < bড়, য়, ং, ৎ ভুল জায়গায়; ইংরেজিতে বড় হাতের নাম আগেlocaleCompare(b, "bn") বা Intl.Collator
a === b দিয়ে ব্যবহারকারীর লেখা মেলানোয়/ড় দুই রূপে থাকলে মেলে নাদুই দিকেই normalize("NFC"), তারপর তুলনা

বাংলা লেখা নিয়ে কোনো কাজে AI-কে সাহায্য চাইলে শুরুতেই প্রেক্ষাপট দিন — কোন অর্থে “অক্ষর”, কোন পরিবেশ, আর পরীক্ষার ডেটা:

আমি JavaScript-এ বাংলা লেখা নিয়ে কাজ করছি। কাজটি: [যেমন: ইনপুট বক্সে "আর কত অক্ষর বাকি" দেখানো] নিয়ম: ১. "অক্ষর" মানে মানুষের চোখের অক্ষর (grapheme) — .length দিয়ে গুনবে না; Intl.Segmenter ব্যবহার করো। ২. split('') বা substr ব্যবহার করবে না; কাটতে হলে grapheme ধরে কাটবে। ৩. বাংলা শব্দ সাজাতে বা তুলনা করতে localeCompare(…, "bn") বা Intl.Collator দাও, আর তুলনার আগে normalize("NFC"). ৪. এই ইনপুটগুলো দিয়ে প্রত্যাশিত আউটপুট লিখে দাও: "বাংলা", "ক্ষমা", "বাংলা🙂", "\u09DF" বনাম "\u09AF\u09BC". ৫. কোন অংশের ফল ব্রাউজারের Unicode version-এর ওপর নির্ভর করতে পারে, সেটা আলাদা করে বলো।

AI-এর string কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১JavaScript-এর string কেন UTF-16? “একটি অক্ষর = একটি ঘর” স্বপ্নটা কীভাবে ভেঙে গেল?

গল্পটা শুরু ১৯৯১ সালে, যখন Unicode 1.0 প্রকাশিত হয়। তখনকার নকশাকারদের বিশ্বাস ছিল, পৃথিবীর সব লিপির সব অক্ষর ৬৫,৫৩৬টি ঘরে (১৬ বিট) এঁটে যাবে। সেই ধারণা থেকে এল UCS-2: প্রতিটি অক্ষর ঠিক ২ বাইট, প্রতিটি ঘর একটি অক্ষর — সরল, সুন্দর, দ্রুত। বাংলা ছিল সেই প্রথম সংস্করণেই।

এই প্রতিশ্রুতিতে বিশ্বাস করে ১৯৯০-এর দশকের শুরুতে বেশ কয়েকটি বড় সিস্টেম ১৬-বিটের অক্ষর বেছে নেয় — Windows NT, Java (১৯৯৫, তার char ১৬ বিট), আর একই বছর Netscape-এ Brendan Eich-এর তৈরি JavaScript. ১৯৯৭ সালের প্রথম ECMAScript মানদণ্ড string-কে সংজ্ঞায়িত করে “১৬-বিটের unsigned মানের ক্রম” হিসেবে।

কিন্তু শিগগিরই বোঝা গেল ৬৫ হাজার ঘর যথেষ্ট নয় — চীনা-জাপানি-কোরিয়ান লিপির বিরল অক্ষর, ঐতিহাসিক লিপি, গাণিতিক চিহ্ন। ১৯৯৬ সালে Unicode 2.0 সীমা বাড়িয়ে প্রায় ১১ লাখ code point করে, আর পুরোনো ১৬-বিটের সিস্টেমগুলো যাতে না ভাঙে, সেজন্য আনে UTF-16: BMP-এর বাইরের অক্ষর দুটি “surrogate” ঘর জুড়ে লেখা হবে। ২০১০ সালের Unicode 6.0-তে ইমোজি আসার পর সমস্যাটা আর বিরল রইল না — 😀, 🙂 প্রতিদিনের লেখায় ঢুকে পড়ল, সবগুলোই BMP-এর বাইরে।

JavaScript তখন আর ভিত্তি বদলাতে পারত না — “ওয়েব ভাঙা যাবে না” নীতির কারণে কোটি কোটি পাতা .length আর charCodeAt-এর পুরোনো অর্থের ওপর দাঁড়িয়ে। তাই ES2015 পুরোনোটা রেখে পাশে নতুন টুল দেয়: codePointAt, String.fromCodePoint, \u{...}, code point ধরে চলা iterator, আর regex-এর u flag. Java-ও একই পথে হেঁটেছে: Java 5 (২০০৪)-এ codePointAt যোগ হয়। অন্যদিকে Python 3 ভেতরে code point রাখে, আর Rust ও Go string রাখে UTF-8 বাইটে।

মূল উপলব্ধি: আজকের অনেক “অদ্ভুত” আচরণ আসলে অতীতের একটি যুক্তিসঙ্গত সিদ্ধান্তের উত্তরাধিকার। একবার প্রকাশিত মানদণ্ড বদলানো প্রায় অসম্ভব — তাই নতুন ক্ষমতা আসে পাশে নতুন API হয়ে, পুরোনোটি সরিয়ে নয়।

প্র ০২Template literal আসতে ES2015 পর্যন্ত লাগল কেন? এর আগে মানুষ কীভাবে HTML আর বার্তা গড়ত, আর tagged template কোন সমস্যা মেটায়?

প্রায় বিশ বছর JavaScript-এ লেখা গড়ার একমাত্র উপায় ছিল +. AJAX-এর যুগে (২০০৫-এর পর) যখন ব্রাউজারেই পুরো তালিকা, কার্ড, টেবিল বানানো শুরু হলো, কোড ভরে উঠল এমন লাইনে: '<li class="' + cls + '">' + name + '</li>'. একটি উদ্ধৃতিচিহ্ন ভুল হলেই পুরো পাতা ভাঙে। একাধিক লাইনের লেখার জন্য লোকে প্রতি লাইনের শেষে \ দিত, বা লাইনগুলো array-তে রেখে join("\n") করত।

এই শূন্যতা পূরণ করে লাইব্রেরি: Underscore-এর _.template, Mustache (২০০৯), পরে Handlebars — তারা {{name}} ধরনের নিজস্ব ছোট “টেমপ্লেট ভাষা” পার্স করত। কাজ চলত, কিন্তু প্রতিটি লাইব্রেরির নিয়ম আলাদা, আর পার্স করার খরচ চলার সময়ে।

ভাষায় এটি আনতে দেরির কারণ ছিল ভাষার নিজের ইতিহাস। ES4-এর উচ্চাভিলাষী পরিকল্পনা ২০০৮ সালে বাতিল হয়, তারপর ২০০৯-এর ES5 ছিল ছোট, সাবধানী আপডেট। বড় নতুন syntax এল একসাথে ES2015-এ — let/const, class, arrow function, module, আর template literal. নকশায় TC39 কয়েকটি সিদ্ধান্ত নিল: নতুন চিহ্ন backtick, যাতে পুরোনো কোড না ভাঙে; ${}-এর ভেতরে পূর্ণ expression; আর সবচেয়ে চতুর সিদ্ধান্ত — tagged template.

Tagged template মূল সমস্যাটা ধরে: লেখা আর ডেটা মিশিয়ে ফেললে injection হয় — ব্যবহারকারীর নামের জায়গায় কেউ <script> লিখলে সেটি HTML হয়ে যায় (XSS), বা SQL-এ উদ্ধৃতিচিহ্ন দিয়ে query বদলে দেয়। Tag function স্থির অংশ আর মান আলাদা হাতে পায়, তাই প্রতিটি মান নিরাপদে escape করতে বা database-এ parameter হিসেবে পাঠাতে পারে। lit-এর html`...`, styled-components-এর css`...`, বা GraphQL-এর gql`...` এই ক্ষমতার ওপর দাঁড়ানো। আর স্থির অংশের array প্রতিবার একই object থাকে, তাই লাইব্রেরি সেটিকে cache-এর চাবি বানাতে পারে।

মূল উপলব্ধি: ভালো ভাষা-নকশা শুধু সুবিধা দেয় না, নিরাপদ পথকে সহজ পথ বানায়। Template literal জোড়াতালি সরিয়েছে; tagged template ডেটা আর কোডকে আলাদা রাখার একটি কাঠামো দিয়েছে — যদিও সেই নিরাপত্তা নির্ভর করে tag function-টি সঠিকভাবে escape করছে কি না তার ওপর।

প্র ০৩“চোখের অক্ষর” গোনা এত কঠিন কেন যে এর জন্য Intl.Segmenter আর বিশাল data table লাগে? একই শব্দ এক ব্রাউজারে ২, আরেকটিতে ৩ কেন?

কারণ “অক্ষর” কোনো গাণিতিক ধারণা নয়, ভাষাগত ধারণা। Code unit বা code point গোনা সহজ — নির্দিষ্ট সংখ্যা দেখে বোঝা যায় কোথায় একটি শেষ। কিন্তু grapheme-এর সীমা বুঝতে প্রতিটি code point-এর “চরিত্র” জানতে হয়: এটি কি মূল অক্ষর, কার-চিহ্ন (combining mark), হসন্ত, zero-width joiner, ইমোজির চামড়ার রঙ বদলানোর চিহ্ন, না পতাকার অর্ধেক?

Unicode এই নিয়ম লিখেছে Annex #29 (Text Segmentation)-এ, আর প্রতিটি code point-এর জন্য একটি “Grapheme_Cluster_Break” property দিয়েছে। ১১ লাখ সম্ভাব্য code point-এর এই তথ্য একটি বড় টেবিল — আর তা প্রতিটি নতুন Unicode সংস্করণে (প্রায় প্রতি বছর) বদলায়, কারণ নতুন ইমোজি, নতুন লিপি যোগ হয়। একটি ছোট regex দিয়ে এটা নির্ভুলভাবে করা যায় না। তাই ব্রাউজার আর Node.js ভেতরে ICU (International Components for Unicode) লাইব্রেরির ডেটা ব্যবহার করে, যা Intl.Segmenter-এর পেছনে কাজ করে।

বাংলা ও অন্যান্য ভারতীয় লিপির জন্য গল্পটা আরও মজার। বহু বছর Unicode-এর সাধারণ নিয়মে হসন্ত একটি combining mark হিসেবে আগের অক্ষরের সাথে থাকত, কিন্তু পরের অক্ষর আলাদা গুচ্ছ হতো — তাই “ক্ষ” ভাগ হতো “ক্‌” আর “ষ”-তে, যা কোনো বাংলাভাষীর চোখে ঠিক নয়। ২০২৩ সালের Unicode 15.1 একটি নতুন নিয়ম (GB9c) যোগ করে, যাতে বাংলা, দেবনাগরীসহ কয়েকটি লিপির হসন্ত-জোড়া যুক্তাক্ষর এক গুচ্ছ থাকে। ফলে engine-এর ICU কোন Unicode version-এর, তার ওপর ফল নির্ভর করে: আমাদের যাচাইয়ে Node.js 20.17 (Unicode 15.1) আর বর্তমান Edge “ক্ষমা”-কে ২ গুচ্ছ বলে, অথচ পুরোনো নিয়মের পরিবেশে (যেমন Java পর্বে দেখানো OpenJDK 21-এর \X) একই শব্দ ৩ গুচ্ছ। তার ওপর, “বাং”-এর মতো গুচ্ছ কোনো বাংলা শিক্ষক “একটি অক্ষর” বলবেন কি না — সেটাও প্রশ্নসাপেক্ষ।

Intl.Segmenter আসার আগে ডেভেলপাররা grapheme-splitter-এর মতো লাইব্রেরি ব্যবহার করতেন, যা নিজের ভেতরে টেবিল বয়ে বেড়াত — পাতার আকার বাড়ত, আর Unicode আপডেট হলে লাইব্রেরি পুরোনো হয়ে যেত। Segmenter সেই কাজ ব্রাউজারের হাতে দিয়েছে, যার ডেটা ব্রাউজার আপডেটের সাথেই নতুন হয়।

মূল উপলব্ধি: মানুষের ভাষা ডেটা-নির্ভর, নিয়ম-নির্ভর নয়। তাই ভাষাগত প্রশ্নের উত্তর (অক্ষর, শব্দ, ক্রম) নিজে কোড না লিখে প্ল্যাটফর্মের Intl-কে জিজ্ঞেস করুন — আর মনে রাখুন উত্তরটি ডেটার version-এর সাথে বদলাতে পারে, তাই এর ওপর ভিত্তি করে নিখুঁত সংখ্যার টেস্ট লিখলে সাবধান।

প্র ০৪একই দেখতে ভিন্ন লেখা কি শুধু তুলনার ঝামেলা, না নিরাপত্তারও প্রশ্ন? Normalization আর homoglyph-এর সম্পর্ক কী?

দুটি আলাদা সমস্যা আছে, আর দুটিই নিরাপত্তার সাথে জড়িত। প্রথমটি canonical equivalence: একই অক্ষরের দুই রকম code point-রূপ, যেমন য় (U+09DF) বনাম য + ়, বা ফরাসি é একক বনাম e + উপরের দাগ। Unicode নিজেই এদের “সমতুল্য” ঘোষণা করে, আর NFC/NFD (UAX #15) এদের এক রূপে আনে। এটা না করলে একই ব্যবহারকারী-নাম দুইবার নিবন্ধন হতে পারে, বা “নিষিদ্ধ শব্দ” তালিকা অন্য রূপে লেখা শব্দ ধরতে পারে না।

দ্বিতীয়টি আরও সূক্ষ্ম — homoglyph বা “confusable”: সম্পূর্ণ ভিন্ন অক্ষর, যা দেখতে প্রায় এক। ল্যাটিন “a” আর সিরিলিক “а” পর্দায় একই, কিন্তু code point আলাদা, আর কোনো normalization এদের এক করে না, কারণ Unicode-এর চোখে এরা ভিন্ন অক্ষর। আক্রমণকারী এভাবে একটি নকল ডোমেইন বানাতে পারে, যা দেখতে পরিচিত ব্যাংক বা কোম্পানির ঠিকানার মতো। আন্তর্জাতিক ডোমেইন নাম (IDN) ভেতরে “xn--” দিয়ে শুরু হওয়া Punycode-এ রাখা হয়। ২০১৭ সালে একজন গবেষক দেখান, পুরোপুরি সিরিলিক অক্ষরে লেখা একটি ডোমেইন তখনকার Chrome আর Firefox ঠিকানা-বারে একটি বিখ্যাত কোম্পানির নামের মতো দেখাচ্ছে। এরপর ব্রাউজারগুলো নিয়ম কড়া করে: সন্দেহজনক লিপি-মিশ্রণ দেখলে ঠিকানা-বারে Punycode রূপ দেখায়।

রক্ষার উপায়গুলো তাই স্তরে স্তরে:

  • ব্যবহারকারী-নাম, ইমেইল, ট্যাগের মতো পরিচয়-চিহ্ন সংরক্ষণ ও তুলনার আগে সবসময় একই normalization (সাধারণত NFC; পরিচয়ের জন্য কেউ কেউ NFKC) চালানো।
  • Unicode Technical Standard #39-এর “confusables” তালিকা আর একই নামে একাধিক লিপি মেশানো আটকানোর নিয়ম ব্যবহার করা — অনেক সিস্টেম একটি নামে বাংলা আর ল্যাটিন মিশ্রণ সীমিত রাখে।
  • অনুমতির সিদ্ধান্ত কখনো “দেখতে একই” লেখার ওপর না নেওয়া — নিরাপত্তা যাচাই হোক normalize করা মান বা নির্দিষ্ট ID দিয়ে।

মূল উপলব্ধি: মানুষের চোখ আর কম্পিউটারের তুলনা আলাদা জিনিস দেখে। যেখানে এই দুইয়ের ফারাক থাকে, সেখানেই বাগ আর প্রতারণা জন্মায়। তাই যে লেখা পরিচয় বা অনুমতি ঠিক করে, তা আগে একটি নির্দিষ্ট রূপে এনে তারপর তুলনা করুন।

প্র ০৫বাংলা নাম সঠিক ক্রমে সাজানো এত জটিল কেন? localeCompare আর Intl.Collator-এর পেছনে কী আছে?

বর্ণানুক্রম মনে হয় সহজ — অভিধান খুললেই তো আছে! কিন্তু প্রতিটি ভাষার নিয়ম আলাদা, আর অনেক সময় একই ভাষায় একাধিক প্রচলিত নিয়ম থাকে। সুইডিশে “ä” আসে z-এর পরে, জার্মানে a-এর পাশে। স্প্যানিশে একসময় “ch” আলাদা অক্ষর হিসেবে c-এর পরে বসত। বাংলায় প্রশ্ন ওঠে: ড় কি ড-এর পাশে বসবে? ং, ঃ, ঁ কোথায়? “ক্ষ” কি ক-এর ভেতরে, না আলাদা অক্ষর হিসেবে হ-এর পরে — যেমন অনেক পুরোনো অভিধানে দেখা যায়?

এর সাধারণ সমাধান হলো Unicode Collation Algorithm (UTS #10). এটি প্রতিটি অক্ষরকে কয়েক স্তরের “ওজন” দেয়: প্রথম স্তরে মূল অক্ষর (a বনাম b), দ্বিতীয় স্তরে চিহ্ন (a বনাম á), তৃতীয় স্তরে বড়-ছোট হাতের (a বনাম A). তুলনা আগে প্রথম স্তরে হয়, সমান হলে পরের স্তরে। তাই "a".localeCompare("á", "en", { sensitivity: "base" }) দেয় 0 — শুধু মূল অক্ষর দেখলে সমান। প্রতিটি ভাষার নিজস্ব বদল রাখা আছে CLDR (Common Locale Data Repository)-এ, যা Unicode Consortium পরিচালনা করে; ICU সেই ডেটা নিয়ে কাজ করে।

JavaScript-এ এটি এসেছে ২০১২ সালের ECMA-402 (ECMAScript Internationalization API)-এর প্রথম সংস্করণে, Intl.Collator হিসেবে; localeCompare-ও ভাষা আর option নিতে শুরু করে। এর আগে localeCompare-এর আচরণ ব্রাউজারভেদে অনেক আলাদা ছিল। এখনও কিছু পার্থক্য থাকতে পারে, কারণ ব্রাউজার তার নিজের ICU/CLDR version বয়ে বেড়ায়। Intl.Collator আরও কিছু দরকারি ক্ষমতা দেয়: { numeric: true } দিলে "file10" আসে "file2"-এর পরে (সাধারণ sort-এ আগে আসে), আর sensitivity দিয়ে ঠিক করা যায় বড়-ছোট হাতের বা চিহ্নের পার্থক্য গুনবে কি না।

একটি ব্যবহারিক কথা: বড় তালিকা (যেমন হাজারো শিক্ষার্থীর নাম) সাজাতে প্রতিটি তুলনায় localeCompare(b, "bn") ডাকলে প্রতিবার ভাষা-নিয়ম খোঁজার খরচ লাগতে পারে; একবার new Intl.Collator("bn") বানিয়ে তার compare ব্যবহার করা দ্রুত। আর সার্ভারে ডেটাবেসের সাজানো (collation) যেন ব্রাউজারের সাজানোর সাথে মেলে, সেটাও ভাবতে হয় — না হলে পাতা বদলালে তালিকার ক্রম লাফিয়ে যায়।

মূল উপলব্ধি: “সঠিক ক্রম” একটি সাংস্কৃতিক সিদ্ধান্ত, গাণিতিক নয়। Code unit-এর ক্রম মেশিনের সুবিধা; মানুষের জন্য সাজাতে ভাষা জানে এমন collator ব্যবহার করুন, আর কোন locale ব্যবহার করছেন তা স্পষ্ট লিখুন।

অনুশীলন

বেশিরভাগ কাজ ব্রাউজারের console-এ এক লাইন লিখে দেখা আর ভাবা — কোড লিখতে হবে শুধু শেষটিতে।

  1. আগে অনুমান, পরে যাচাই: console-এ চালানোর আগে খাতায় লিখুন প্রতিটির ফল: (ক) "ঢাকা".length (খ) [..."👍🏽"].length (গ) "Bangladesh".slice(-4, -1) (ঘ) "a-b-c".replace("-", "")

    (ক) 4 — ঢ, া, ক, া. (খ) 2 — বুড়ো আঙুলের ইমোজি আর চামড়ার রঙের চিহ্ন দুটি আলাদা code point; .length হবে 4 (দুটিই surrogate pair), কিন্তু চোখে একটি, Segmenter-ও ১টি grapheme দেবে। (গ) "des" — ঘর 6 থেকে 8, শেষ ঘর বাদ। (ঘ) "ab-c" — string দিয়ে খুঁজলে replace শুধু প্রথম মিল বদলায়।

  2. নিরীক্ষা: console-এ লিখুন let s = "rajshahi"; s.toUpperCase(); console.log(s); — কী ছাপা হলো? কেন? কী বদলালে RAJSHAHI ছাপা হবে?

    ছাপা হয় rajshahi. toUpperCase() একটি নতুন string ফেরত দিয়েছে, কিন্তু সেটি কোথাও রাখা হয়নি — হারিয়ে গেছে। string immutable, তাই s যেমন ছিল তেমনই আছে। ঠিক করতে ফলটি variable-এ রাখুন: s = s.toUpperCase(); — এখানে string বদলায়নি, s নামফলকটি নতুন string-এর দিকে সরেছে। নতুনদের মধ্যে এটি অন্যতম সাধারণ বাগ।

  3. তালিকা সাজান: ["রহিম", "করিম", "ং-শব্দ", "আনিকা", "Zara", "anik"] — একবার .sort() দিয়ে, একবার .sort((a, b) => a.localeCompare(b, "bn")) দিয়ে সাজান। কোথায় পার্থক্য দেখলেন, আর কেন?

    .sort() code unit-এর সংখ্যা ধরে সাজায়: সব ল্যাটিন অক্ষর (সংখ্যা ছোট) বাংলার আগে, তার ভেতরেও বড় হাতের “Zara” ছোট হাতের “anik”-এর আগে; বাংলায় “ং” (U+0982) সব স্বরবর্ণের আগে চলে আসে। localeCompare(…, "bn") ভাষার নিয়ম মানে: Node.js 20 আর Edge-এ ফল ["আনিকা", "ং-শব্দ", "করিম", "রহিম", "anik", "Zara"] — “bn” locale-এ বাংলা লিপি আগে, ল্যাটিন পরে; “anik” আসে “Zara”-র আগে; আর “ং” স্বরবর্ণের পরে বসে। কোন লিপি আগে আসবে, সেটিও collator-এর ডেটার সিদ্ধান্ত — “en” দিলে ক্রম অন্যরকম হতে পারে, চালিয়ে দেখুন।

  4. ছোট কোড লিখুন: shorten(text, n) নামে একটি function লিখুন, যা লেখা n-টি grapheme-এর বেশি হলে প্রথম n-টি রেখে শেষে “…” লাগায়। পরীক্ষা করুন shorten("আমি বাংলায় গান গাই🙂", 5) দিয়ে।

    একটি সমাধান:

    const seg = new Intl.Segmenter("bn", { granularity: "grapheme" });
    function shorten(text, n) {
      const parts = [...seg.segment(text)].map(x => x.segment);
      return parts.length <= n ? text : parts.slice(0, n).join("") + "…";
    }

    Node.js 20 আর বর্তমান Edge-এ এটি দেয় "আমি বাংলা…". প্রথম ৫টি গুচ্ছ হলো আ · মি · (space) · বাং · লা — খেয়াল করুন, space-ও একটি grapheme. কাটা হয় গুচ্ছের সীমায়, তাই কার-চিহ্ন, যুক্তাক্ষর বা ইমোজি কখনো অর্ধেক হয় না। slice(0, n) সেই নিশ্চয়তা দেয় না — সেটি code unit গোনে, তাই slice(0, 5) দিত শুধু “আমি ব”।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমফর্মে টাইপ করা ফোন নম্বর “ ০১৭১২-৩৪৫৬৭৮ ” পরিষ্কার করে যাচাইয়ের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📥 ইনপুট বক্স থেকে লেখাটি নেওয়া
  2. 🧹 পরিষ্কার করা — trim() ও replaceAll দিয়ে space আর হাইফেন বাদ, বাংলা অঙ্ককে ইংরেজি অঙ্কে রূপান্তর
  3. ✅ পরিষ্কার লেখাটি startsWith("01") আর length === 11 দিয়ে যাচাই
  4. 💾 যাচাই পাস করলে তবেই নম্বরটি সংরক্ষণ বা সার্ভারে পাঠানো

JavaScript-এ "বাংলা".length-এর মান কত?

  • 2
  • 15
  • 5
  • 3

let s = "dhaka"; s.toUpperCase(); console.log(s); — কী ছাপা হবে?

  • DHAKA
  • dhaka
  • TypeError
  • undefined

const s = "Bangladesh"; হলে s.substring(-4) কী দেয়?

  • "desh"
  • RangeError
  • "" (খালি string)
  • "Bangladesh"

🙂 (U+1F642) ইমোজির জন্য "🙂".length 2 হয় কেন?

  • এর code point ১৬ বিটে আঁটে না, তাই UTF-16-এ দুটি code unit (surrogate pair) লাগে
  • ইমোজিটি আসলে দুটি আলাদা অক্ষর জুড়ে তৈরি
  • .length বাইট গোনে, আর ইমোজি ২ বাইট
  • এটি ব্রাউজারের একটি পরিচিত বাগ, নতুন version-এ 1 হবে

মানুষকে দেখানোর জন্য বাংলা নামের একটি তালিকা বর্ণানুক্রমে সাজাতে কোনটি সবচেয়ে উপযুক্ত?

  • names.sort()
  • names.sort((a, b) => (a < b ? -1 : 1))
  • names.sort((a, b) => a.localeCompare(b, "bn"))
  • names.sort((a, b) => a.length - b.length)

পর্দায় একই রকম দেখায় এমন দুটি বাংলা string (একটিতে য় একক code point, আরেকটিতে য + নুক্তা) === দিয়ে তুলনায় false দিচ্ছে। সঠিক সমাধান কোনটি?

  • দুটিতেই trim() চালিয়ে তুলনা
  • দুটিতেই normalize("NFC") চালিয়ে তারপর === দিয়ে তুলনা
  • === বদলে == ব্যবহার
  • দুটিতেই toLowerCase() চালিয়ে তুলনা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. string = immutable UTF-16 code unit-এর সারি। প্রতিটি method নতুন string দেয়; ফল রাখতে চাইলে variable-এ বসান।
  2. লেখা গড়তে template literal (`${x}`); path বা regex-এর জন্য String.raw.
  3. কাটতে slice (ঋণাত্মক index চলে), একটি ঘরে at(-1); substr legacy, substring ঋণাত্মক বোঝে না।
  4. মানুষের ক্রমে সাজাতে localeCompare(b, "bn") বা Intl.Collator; তুলনার আগে normalize("NFC").
  5. .length = code unit, [...s] = code point, Intl.Segmenter = চোখের অক্ষর — আর grapheme-এর ফল engine-এর Unicode version-এর ওপর নির্ভর করে।

পরের পর্বে: লেখার পর সংখ্যা। 0.1 + 0.2 কেন 0.3 নয়, টাকার হিসাবে তাহলে কী করবেন, Math-এর দরকারি টুল আর খুব বড় পূর্ণসংখ্যার জন্য BigInt.

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান