Foundationপ্রথম নীতি থেকে
LEVEL 1লেসন ১২/১৪কঠিন১ ঘণ্টা ৫ মিনিট

বাংলা Text Encoding ও Grapheme Cluster — যখন এক Code Point এক অক্ষর নয়

Bangla Text Encoding and Grapheme Clusters

বাংলার মতো Brahmic script-এ একটা code point মানে সবসময় একটা visual 'অক্ষর' নয় — যুক্তাক্ষর আর matra মিলিয়ে একাধিক code point একটা grapheme cluster গঠন করে, আর এই পার্থক্য উপেক্ষা করলেই জন্ম নেয় cursor bug, truncation corruption, আর character-count ভুল।

এই লেসন শেষে আপনি পারবেন

  • Bengali Unicode block-এর গঠন — independent vowel, consonant, matra, virama — চিনতে ও ব্যাখ্যা করতে পারবেন
  • যুক্তাক্ষর (conjunct) কীভাবে virama দিয়ে গঠিত হয়, আর matra কেন visually reorder হয় তা ব্যাখ্যা করতে পারবেন
  • একটা বাংলা শব্দের code point, UTF-8 byte, আর grapheme cluster সংখ্যা হাতে-কলমে আলাদা করে গণনা করতে পারবেন
  • কেন naive len() বাংলায় ভুল সংখ্যা দেয় তা প্রমাণ করতে পারবেন, এবং একটা manual grapheme segmenter লিখতে পারবেন
  • NFC/NFD normalization বাংলায় কীভাবে 'দেখতে একই কিন্তু string-এ ভিন্ন' bug তৈরি করে তা প্রদর্শন করতে পারবেন
  • Cursor movement, VARCHAR truncation, character-count UI-তে grapheme cluster উপেক্ষা করার বাস্তব পরিণতি চিনতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আগের লেসনের শেষ misconception-টা এখানেই খুলে ধরব। Unicode আর UTF-8 সমাধান করেছে “কোন byte কোন code point” প্রশ্ন। কিন্তু একটা গভীরতর প্রশ্ন এখনো বাকি: একটা code point কি সবসময় একটা “অক্ষর”?

ইংরেজিতে প্রায় সবসময় হ্যাঁ। "hello" — ৫টা code point, ৫টা visual glyph, ৫টা জিনিস যা একজন ইংরেজি-ভাষী “অক্ষর” বলবেন। len() আর মানুষের অনুভূতি একসাথে চলে।

বাংলায় এই সমীকরণটা ভেঙে পড়ে। এই লাইনটা নিন:

>>> len("বিদ্যা")
6

ছয়? একজন বাংলাভাষী “বিদ্যা” শব্দটা দেখে বলবেন এটাতে দুইটা syllable/অক্ষরগুচ্ছ — “বি” আর “দ্যা”। কম্পিউটার বলছে ছয়টা কিছু। কোনটা ভুল?

কেউই ভুল না। কম্পিউটার ঠিকই ছয়টা code point গুনছে — len() তার সংজ্ঞা অনুযায়ী সঠিক। কিন্তু মানুষ যেটা “একটা অক্ষর” বলে অনুভব করে, প্রযুক্তিতে তার একটা আলাদা নাম আছে — grapheme cluster — আর সেটা code point-এর সাথে এক জিনিস না।

এই পার্থক্যটা এই পুরো প্ল্যাটফর্মের জন্য বিশেষভাবে গুরুত্বপূর্ণ, কারণ আপনি যে সাইটে এই লেসনটা পড়ছেন সেটা সম্পূর্ণ বাংলায় লেখা — আর এই লেসনের শেষে আপনি বুঝবেন কেন cursor সরানো, backspace চাপা, database column-এর length ঠিক করা, বা একটা character-count UI বানানো — এই প্রতিটা কাজে বাংলা টেক্সট একটা লুকানো ফাঁদ বহন করে যা ইংরেজি-কেন্দ্রিক চিন্তা করে লেখা কোডে ধরা পড়ে না।

মূল ধারণা

Bengali Unicode block — U+0980 থেকে U+09FF

বাংলা লিপির সবগুলো মূল অক্ষর এই ১২৮-code-point ব্লকে থাকে (কিছু বর্ধিত চিহ্ন block-এর বাইরেও আছে, কিন্তু মূল লিপি এখানেই)।

বিভাগউদাহরণপরিসর (আংশিক)
স্বাধীন স্বরবর্ণ (independent vowel)অ আ ই ঈ উ ঊ এ ঐ ও ঔU+0985–U+0994
ব্যঞ্জনবর্ণ (consonant)ক খ গ ঘ ঙ … হU+0995–U+09B9
স্বরচিহ্ন/কার (dependent vowel sign, matra)া ি ী ু ূ ে ৈ ো ৌU+09BE–U+09CC
হসন্ত (virama)U+09CD
নাসিক্য/বিসর্গ চিহ্নং (অনুস্বার) ঃ (বিসর্গ) ঁ (চন্দ্রবিন্দু)U+0982, U+0983, U+0981
সংখ্যা০ ১ ২ ৩ ৪ ৫ ৬ ৭ ৮ ৯U+09E6–U+09EF
Bengali Unicode block-এর মূল বিভাগ — এই কাঠামোটা সরাসরি ISCII (IS 13194:1991)-এর নকশা থেকে উত্তরাধিকারসূত্রে পাওয়া, শুধু ভিন্ন সংখ্যায় স্থানান্তরিত।

লক্ষ্য করুন এই তালিকায় consonant আর vowel sign আলাদা বিভাগ — এটাই মূল চাবিকাঠি বুঝতে কেন বাংলা “এক code point = এক glyph” নিয়ম মানে না।

বাংলা একটা abugida — inherent vowel-এর ধারণা

বাংলা (আর দেবনাগরী, তামিল, থাই-সহ বহু দক্ষিণ/দক্ষিণ-পূর্ব এশীয় script) একটা abugida — একটা লিখন পদ্ধতি যেখানে প্রতিটা consonant অক্ষর নিজে থেকেই একটা inherent vowel (“অ”-এর মতো শব্দ) বহন করে, আলাদাভাবে না লিখেই।

ক   একা লিখলে পড়া হয় "ক" (ব্যঞ্জন + অন্তর্নিহিত "অ" স্বর)

এই inherent vowel বদলাতে বা সরাতে তিনটা উপায়:

১. Matra (dependent vowel sign) জুড়ে দেওয়া — inherent “অ” বদলে অন্য স্বরে রূপান্তর:

ক + ি (vowel sign I)  = কি   ("কি", "কি" স্বরে)
ক + ে (vowel sign E)  = কে   ("কে")

২. Virama (হসন্ত) দিয়ে inherent vowel সম্পূর্ণ বাতিল করা — consonant-টা তখন “খালি” (শুধু ব্যঞ্জনধ্বনি, কোনো স্বর ছাড়া):

ক + ্ (virama) = ক্   (শুধু "ক্" ধ্বনি, কোনো স্বর নেই)

৩. Virama-র পর আরেকটা consonant বসালে — যুক্তাক্ষর (conjunct) তৈরি:

ক + ্ + ষ = ক্ষ   ("kkho" — দুইটা consonant একসাথে ফিউজড একটা glyph-এ)

এই তৃতীয় কেসটাই সবচেয়ে গুরুত্বপূর্ণ — আর সবচেয়ে জটিল।

যুক্তাক্ষর (Conjunct) — একাধিক code point, একটা glyph

যখন একটা consonant-এর পর virama আসে, আর virama-র পর আরেকটা consonant আসে, রেন্ডারিং ইঞ্জিন (font shaping) সেই দুই consonant-কে একটা সংযুক্ত glyph হিসেবে আঁকে — আলাদা দুইটা অক্ষর পাশাপাশি না বসিয়ে।

যুক্তাক্ষরগঠনধরন
ক্ষক + ্ + ষসম্পূর্ণ অনিয়মিত — মূল দুই অক্ষরের সাথে দৃশ্যত সম্পর্কহীন নতুন আকার
জ্ঞজ + ্ + ঞসম্পূর্ণ অনিয়মিত — আরেকটা ঐতিহাসিক ফিউজড glyph
ত্রত + ্ + রআধা-নিয়মিত — “র” এখানে বিশেষ রূপ নেয় (ref-form)
ন্ধন + ্ + ধনিয়মিত স্ট্যাকিং — নিচে-উপরে জোড়া
স্বস + ্ + বনিয়মিত স্ট্যাকিং
ঙ্কঙ + ্ + কনিয়মিত স্ট্যাকিং
পরিচিত কিছু যুক্তাক্ষর — কিছু নিয়মিতভাবে স্ট্যাক করা, কিছু সম্পূর্ণ নতুন, অনিয়মিত আকার নেয়।

ক্ষ আর জ্ঞ বিশেষভাবে লক্ষণীয় — এগুলোর ফলাফল আকৃতি তাদের উপাদান অক্ষরের সাথে দৃশ্যত প্রায় কোনো মিলই নেই। একজন নতুন পাঠক ক্ষ দেখে সহজে বুঝবেন না এটা “ক” আর “ষ”-এর মিলন — এটা ঐতিহাসিকভাবে বিবর্তিত একটা প্রায়-স্বাধীন glyph, ঠিক যেমন ইংরেজি ”&” চিহ্নটা আসলে ল্যাটিন “et” (and) শব্দের একটা প্রাচীন ligature, কিন্তু আজ কেউ সেটা “e” আর “t” হিসেবে দেখেন না।

গুরুত্বপূর্ণ: এই যুক্তাক্ষরগুলোর জন্য Unicode-এ কোনো আলাদা, dedicated code point নেই (কয়েকটা বিরল ঐতিহাসিক ব্যতিক্রম বাদে)। ক্ষ সবসময় তিনটা code point দিয়ে গঠিত — (U+0995) + (U+09CD) + (U+09B7)। রেন্ডারিং ইঞ্জিন (যেমন HarfBuzz, যা Chrome, Android, Linux-এ ব্যবহৃত হয়) রানটাইমে এই তিনটা code point দেখে সিদ্ধান্ত নেয় কোন যুক্ত glyph আঁকতে হবে, font-এর ভেতরে সংরক্ষিত ligature rule অনুযায়ী।

Matra reordering — লেখা হয় পরে, দেখানো হয় আগে

একটা আরো সূক্ষ্ম জটিলতা: কিছু vowel sign (matra) visually consonant-এর আগে বসে, যদিও Unicode-এ (এবং keyboard input-এ) সেগুলো consonant-এর পরে লেখা হয়।

Logical/storage order:   ক  +  ি   (KA, তারপর vowel-sign-I)
Visual rendering:        ি  ক     (vowel sign আগে দেখানো হয়!)
                          =  কি

এটাকে বলা হয় “pre-base vowel sign” বা reordering matra। বাংলায় ি (vowel sign I) আর / (E/AI-এর প্রথম অংশ) এই আচরণ দেখায়।

কেন এভাবে ডিজাইন হলো: এটা phonetic order অনুসরণ করে, visual order না — বাংলা উচ্চারণে “ক” ধ্বনির আগে “ই” ধ্বনি কল্পনা করা হয় না, কিন্তু লেখার রীতিতে ঐতিহাসিকভাবে হাতের লেখায় এই matra-টা consonant-এর বাম দিকে আঁকার প্রচলন তৈরি হয়েছিল। Unicode এনকোডিং যুক্তিসঙ্গতভাবে উচ্চারণ/টাইপিং ক্রম অনুসরণ করে (consonant আগে, matra পরে — যেটা কীবোর্ডেও স্বাভাবিক টাইপিং ক্রম), আর রেন্ডারিং ইঞ্জিনের দায়িত্ব সেটাকে সঠিক visual position-এ আঁকা।

এই পার্থক্যটা “physical reality → abstraction” নীতির একটা সরাসরি উদাহরণ: storage order আর display order দুটো আলাদা স্তর, আর যেকোনো টেক্সট-প্রসেসিং কোড যদি ধরে নেয় “যা storage-এ আগে আছে তাই স্ক্রিনে আগে দেখাবে”, বাংলায় সেই ধারণা ভুল প্রমাণিত হবে।

চারটা ভিন্ন “length” — একই শব্দ, চারটা সংখ্যা

এখন আমরা প্রস্তুত মূল সমস্যাটা স্পষ্টভাবে দেখার জন্য। “বাংলা” শব্দটা নিন — সবচেয়ে সরল কেস, কোনো conjunct নেই, শুধু consonant + matra + anusvara:

বাংলা  =  ব + া + ং + ল + া
         (BA) (AA-sign) (anusvara) (LA) (AA-sign)
পরিমাপসংখ্যাকী গণনা করছে
Code point (Python len())Unicode-এর প্রতিটা স্বতন্ত্র “সংখ্যাগত ইউনিট”
UTF-16 code unit (JS .length)(বাংলা পুরোপুরি BMP-তে, তাই code point-এর সমান)
UTF-8 byte১৫প্রতিটা code point ৩ byte নেয় (৫ × ৩)
Grapheme cluster (মানুষের “অক্ষর”)“বাং” + “লা” — visually দুইটা আলাদা ব্লক
'বাংলা' শব্দের চারটা ভিন্ন 'দৈর্ঘ্য' — কোনটাই ভুল না, কিন্তু চারটাই ভিন্ন প্রশ্নের উত্তর দেয়।

চারটা সংখ্যা: ৫, ৫, ১৫, ২। প্রতিটা তার নিজের সংজ্ঞায় সঠিক। কিন্তু যদি আপনার কোড এই চারটাকে গুলিয়ে ফেলে — ধরে নেয় “length” মানে সবসময় “মানুষের চোখে দেখা অক্ষর সংখ্যা” — bug অনিবার্য।

কীভাবে “বাং” আর “লা” এই দুই ভাগে ভাঙল, সেটা দেখুন:

  • একটা নতুন cluster শুরু করে (স্বাধীন consonant)
  • (matra) আগের consonant-এ যুক্ত হয় → “বা”
  • (anusvara) — একটা spacing mark, আগের cluster-এই যুক্ত হয় → “বাং”
  • — এর ঠিক আগে কোনো virama নেই, তাই এটা নতুন cluster শুরু করে → “ল”
  • (matra) আবার আগের cluster-এ যুক্ত হয় → “লা”

নিয়মটা সহজ করে বলা যায়: একটা নতুন grapheme cluster শুরু হয় প্রতিটা “স্বাধীন” consonant বা vowel-এ (যার ঠিক আগে virama নেই)। Matra, anusvara, virama, আর virama-অনুসারী consonant — এসব সবই আগের cluster-এ যুক্ত থাকে।

ভেতরে কী ঘটছে

যেখানে “এক code point = এক অক্ষর” ধারণাটা ভেঙে পড়ে

১. Grapheme cluster-এর সংজ্ঞাও স্থির নয় — এমনকি Unicode standard নিজেই এতে ভুগেছে

Unicode-এ “grapheme cluster” বোঝার একটা আনুষ্ঠানিক অ্যালগরিদম আছে — UAX #29 (Text Segmentation)। এটা বলে দেয় ঠিক কোথায় কোথায় একটা code point sequence-কে “ভাঙা” নিরাপদ, কোথায় নয়।

সমস্যা হলো: virama যুক্ত conjunct সঠিকভাবে ধরার নিয়মটা ঐতিহাসিকভাবে অসম্পূর্ণ ছিল। পুরনো ডিফল্ট নিয়মে virama-কে “Extend” ক্যাটাগরির character হিসেবে ট্রিট করা হতো (accent mark-এর মতো, যা আগের base-এ লেগে থাকে) — কিন্তু virama-র পরের consonant-টা একটা সম্পূর্ণ “নতুন base character”, আর পুরনো নিয়মে নতুন base মানেই নতুন cluster শুরু। ফলে পুরনো ডিফল্ট অ্যালগরিদম ক্ষ-কে দুই ভাগে ভেঙে ফেলত{ক, ্} একটা cluster, {ষ} আরেকটা — যদিও visually এটা একটা অখণ্ড glyph।

Unicode 15.0 (২০২২)-এ UAX #29-এ একটা বিশেষ সংশোধন আনা হয় — virama দিয়ে যুক্ত consonant sequence-কে (Brahmic script-এর জন্য) একটা একক grapheme cluster হিসেবে গণ্য করার নিয়ম, ঠিক এই সমস্যাটা সমাধান করতে।

এই ইতিহাসটা নিজেই একটা শিক্ষা: “grapheme cluster” কোনো চিরস্থায়ী, version-নিরপেক্ষ গাণিতিক ধ্রুবক না — এটা একটা engineering approximation, যা মানুষের visual perception-এর কাছাকাছি পৌঁছানোর চেষ্টা করে, আর সময়ের সাথে সংশোধিত হয়। একটা লাইব্রেরি কোন Unicode সংস্করণ ব্যবহার করছে তার উপর নির্ভর করে বাংলা conjunct-এ grapheme segmentation-এর ফলাফল ভিন্ন হতে পারে — এটা সত্যিই ঘটেছে বাস্তবে, বহু লাইব্রেরির পুরনো সংস্করণে।

২. Backspace-এ যুক্তাক্ষর ভেঙে পড়া — ধাপে ধাপে

কল্পনা করুন একটা টেক্সট এডিটর যেটা naive-ভাবে প্রতি backspace-এ একটা code point মোছে (grapheme cluster-সচেতন নয়)। ব্যবহারকারী “বিদ্যা” লিখে backspace চাপতে শুরু করলেন:

'বিদ্যা'-তে naive backspace — প্রতিটা চাপে কী দেখায়
  1. শুরু: "বিদ্যা"৬টা code point: ব ি দ ্ য া
  2. Backspace ১"া" (শেষ matra) মুছে যায় → "বিদ্য" — conjunct দৃশ্যমান কিন্তু matra হারিয়েছে
  3. Backspace ২"য" মুছে যায় → "বিদ্" — একটা dangling virama, ভাঙা/অস্বাভাবিক রেন্ডারিং
  4. Backspace ৩"্" (virama) মুছে যায় → "বিদ" — হঠাৎ conjunct সম্পূর্ণ ভেঙে সাধারণ "দ" হয়ে গেল
  5. Backspace ৪"দ" মুছে যায় → "বি" — এখন প্রথম syllable-এ ফিরে এসেছে

লক্ষ্য করুন ৪টা backspace লাগল একটামাত্র visually-perceived “দ্যা” ব্লক মুছতে (এমনকি matra বাদ দিয়েও conjunct-টাই ৩টা code point) — আর প্রতিটা মাঝের ধাপে স্ক্রিনে একটা অদ্ভুত, ভাঙা, অসম্পূর্ণ রেন্ডারিং দেখা যায় (dangling virama, বিচ্ছিন্ন matra)। এটা ব্যবহারকারীর জন্য বিভ্রান্তিকর এবং দৃষ্টিকটু — একজন বাংলাভাষী আশা করেন এক backspace-এ এক “syllable” মুছে যাক, code point না।

গ্রাফিম-সচেতন এডিটর (grapheme cluster boundary মেনে চলে) একই পরিস্থিতিতে ঠিক ২টা backspace লাগাবে — প্রথমটায় পুরো “া” matra-সহ “দ্যা” ব্লক একসাথে মুছে “বি” রেখে যাবে, পরেরটায় “বি” মুছে খালি স্ট্রিং।

৩. VARCHAR truncation — ডেটাবেসে conjunct কাটা পড়া

অনেক ডেটাবেস VARCHAR(n)-এর n code point (বা কিছু ক্ষেত্রে byte) গণনা করে, grapheme cluster না। ধরুন একটা কলাম VARCHAR(5), আর কেউ “বিদ্যালয়” (school, দীর্ঘতর শব্দ) সংরক্ষণ করতে চাইছেন — code point সংখ্যা যদি ৫-এর বেশি হয়, truncation ঘটবে code point সীমানায়, grapheme boundary-তে না।

মূল শব্দ:        বিদ্যালয়   (৮ code point)
৫ code point-এ truncate: বিদ্য     (virama-সহ conjunct শেষে অসম্পূর্ণ কাটা!)

যদি কাটা পয়েন্টটা ঠিক conjunct-এর মাঝখানে পড়ে (যেমন consonant আছে কিন্তু তার virama-linked পরের consonant বাদ পড়ে গেছে), ফলাফল একটা dangling virama দিয়ে শেষ হওয়া string — visually ভাঙা, আর অনেক downstream system-এ (search index, display) অপ্রত্যাশিত আচরণ তৈরি করতে পারে।

৪. Normalization — দেখতে অভিন্ন, string হিসেবে ভিন্ন

এখানে একটা সম্পূর্ণ নতুন ধরনের সমস্যা। কিছু বাংলা vowel sign (matra) আসলে দুইটা ছোট অংশ দিয়ে গঠিত visually, আর Unicode তাদের জন্য দুই ধরনের representation অনুমোদন করে যা একে অপরের canonical equivalent — মানে দেখতে/অর্থে সম্পূর্ণ অভিন্ন, কিন্তু byte/code-point হিসেবে ভিন্ন।

উদাহরণ — “ো” (vowel sign O):

NFC (precomposed, ১ code point):    ো  = U+09CB
NFD (decomposed, ২ code point):     ে + া = U+09C7 + U+09BE

দুটোই ঠিক একই রকম দেখতে, ঠিক একই উচ্চারণ বোঝায় — কিন্তু string হিসেবে ভিন্ন:

>>> nfc = "\u09CB"          # precomposed O
>>> nfd = "\u09C7\u09BE"    # decomposed E + AA
>>> nfc == nfd
False                        # ভিজ্যুয়ালি একই, string হিসেবে ভিন্ন!

কেন এটা ঘটে বাস্তবে: বিভিন্ন কীবোর্ড layout, input method (Avro, Bijoy Unicode-সংস্করণ), অথবা অপারেটিং সিস্টেম টাইপ করার সময় ভিন্ন internal sequence তৈরি করতে পারে — কেউ সরাসরি precomposed form পাঠায়, কেউ ধাপে ধাপে decomposed form টাইপ করে যা কখনো স্বয়ংক্রিয়ভাবে composed হয় না। ফলে দুইজন ব্যবহারকারী দেখতে হুবহু একই টেক্সট টাইপ করলেও, তাদের সংরক্ষিত byte সম্পূর্ণ ভিন্ন হতে পারে।

এই একই “একাধিক বৈধ representation, filter আর interpretation বিভক্ত” প্যাটার্ন আমরা আগের লেসনে overlong UTF-8 encoding-এ দেখেছি — এখানে আবার দেখা যাচ্ছে, ভিন্ন প্রেক্ষাপটে। মূল শিক্ষা একই থাকে: যেখানেই একাধিক canonical-equivalent representation সম্ভব, সেখানেই normalize-first একটা অপরিহার্য নিয়ম।

উদাহরণ

সম্পূর্ণ worked example — “বিদ্যা” byte-বাই-byte

বিদ্যা  =  ব  +  ি  +  দ  +  ্  +  য  +  া
          BA   I-sign  DA  virama  YA  AA-sign
        U+09AC U+09BF U+09A6 U+09CD U+09AF U+09BE

ধাপ ১ — Code point গণনা: ৬টা।

ধাপ ২ — UTF-8 byte এনকোডিং (আগের লেসনের ৩-byte সূত্র প্রয়োগ করে, প্রতিটা Bengali code point-ই U+0800–U+FFFF রেঞ্জে):

Code pointঅক্ষরUTF-8 bytes
U+09ACE0 A6 AC
U+09BFিE0 A6 BF
U+09A6E0 A6 A6
U+09CDE0 A7 8D
U+09AFE0 A6 AF
U+09BEE0 A6 BE
'বিদ্যা'-র প্রতিটা code point-এর UTF-8 byte — মোট ১৮ byte, ৬টা code point-এর প্রতিটাই ৩ byte।

মোট: ৬ × ৩ = ১৮ byte

>>> "বিদ্যা".encode('utf-8').hex()
'e0a6ace0a6bfe0a6a6e0a78de0a6afe0a6be'
>>> len("বিদ্যা".encode('utf-8'))
18

ধাপ ৩ — Grapheme cluster গণনা (নিয়ম: প্রতিটা স্বাধীন consonant নতুন cluster শুরু করে; matra, virama, আর virama-অনুসারী consonant আগেরটায় যুক্ত থাকে):

ব   →  নতুন cluster শুরু: "ব"
ি   →  matra, আগেরটায় যুক্ত: "বি"          ← cluster ১ সম্পূর্ণ
দ   →  নতুন cluster শুরু (আগে virama নেই): "দ"
্   →  virama, আগেরটায় যুক্ত: "দ্"
য   →  virama-র ঠিক পরে — conjunct, একই cluster-এ যুক্ত: "দ্য"
া   →  matra, আগেরটায় যুক্ত: "দ্যা"          ← cluster ২ সম্পূর্ণ

ফলাফল: ২টা grapheme cluster — “বি” আর “দ্যা”।

এটাই একজন বাংলাভাষীর স্বজ্ঞা নিশ্চিত করে — “বিদ্যা” দুইটা syllable-এর মতো visual block, “বি-দ্যা”।

পরিমাপসংখ্যা
Code point
UTF-16 code unit৬ (BMP-এর ভেতরে, surrogate লাগে না)
UTF-8 byte১৮
Grapheme cluster (মানুষের “অক্ষর”)
'বিদ্যা'-র চারটা 'দৈর্ঘ্য' — একনজরে।

আরেকটা উদাহরণ — “ক্ষমা” (forgiveness), বিখ্যাত অনিয়মিত conjunct সহ

ক্ষমা  =  ক  +  ্  +  ষ  +  ম  +  া
         KA  virama SSA  MA  AA-sign

৫টা code point। Grapheme grouping:

ক   →  নতুন cluster: "ক"
্   →  virama যুক্ত: "ক্"
ষ   →  virama-র পরে, conjunct: "ক্ষ"        ← cluster ১ (এই সেই বিখ্যাত অনিয়মিত glyph)
ম   →  নতুন cluster (আগে virama নেই): "ম"
া   →  matra যুক্ত: "মা"                    ← cluster ২

ফলাফল: ২টা cluster — “ক্ষ” আর “মা”। ৫টা code point, ১৫ byte UTF-8-এ, কিন্তু মানুষের চোখে ২টা ব্লক — “kkho-ma”।

নিজে চালিয়ে দেখুন

EXPERIMENT

নিজের হাতে Bengali grapheme segmenter লিখুন এবং len()-এর মিথ্যা ধরুন

Python 3· ২০ মিনিট
# Bengali script-এর জন্য code point ক্যাটাগরি — Unicode block থেকে সরাসরি
BENGALI_VIRAMA = '\u09CD'

# matra (dependent vowel sign) + nasal/visarga marks — সবই আগের
# cluster-এ যুক্ত হয়, কখনো নিজে নতুন cluster শুরু করে না
BENGALI_TRAILING_MARKS = set(
    '\u0981\u0982\u0983'              # চন্দ্রবিন্দু, অনুস্বার, বিসর্গ
    '\u09BE\u09BF\u09C0\u09C1\u09C2'  # া ি ী ু ূ
    '\u09C3\u09C4'                    # ৃ ৄ (vocalic r/rr sign)
    '\u09C7\u09C8'                    # ে ৈ
    '\u09CB\u09CC'                    # ো ৌ
    '\u09D7'                          # ৗ (AU length mark)
)


def bengali_graphemes(s: str) -> list[str]:
    """virama আর matra-র নিয়ম দিয়ে manual grapheme cluster ভাঙা।
    এটা UAX #29-এর একটা সরলীকৃত, বাংলা-নির্দিষ্ট সংস্করণ — পুরো
    algorithm-এর বদলে শুধু মূল যুক্তিটা প্রয়োগ করছে।"""
    if not s:
        return []
    clusters = []
    i = 0
    n = len(s)
    while i \< n:
        cluster = s[i]
        i += 1
        # Virama-linked conjunct chain শুষে নিন — একাধিক
        # consonant পরপর জোড়া লাগতে পারে (ত্রিস্তরীয় conjunct-ও সম্ভব)
        while i + 1 \< n and s[i] == BENGALI_VIRAMA:
            cluster += s[i] + s[i + 1]
            i += 2
        # শেষে যেকোনো matra/nasal mark যুক্ত করুন
        while i \< n and s[i] in BENGALI_TRAILING_MARKS:
            cluster += s[i]
            i += 1
        clusters.append(cluster)
    return clusters


# ── len() এর মিথ্যা প্রমাণ করুন ──────────────────────────
words = ["বাংলা", "বিদ্যা", "ক্ষমা", "স্বাধীনতা", "বিশ্ববিদ্যালয়"]

print(f"{'শব্দ':\<18} {'code point':>10} {'UTF-8 byte':>10} {'grapheme':>9}  বিভাজন")
print("─" * 72)
for w in words:
    graphemes = bengali_graphemes(w)
    cp = len(w)
    b = len(w.encode('utf-8'))
    g = len(graphemes)
    print(f"{w:\<18} {cp:>10} {b:>10} {g:>9}  {graphemes}")

প্রত্যাশিত আউটপুট:

শব্দ                code point UTF-8 byte  grapheme  বিভাজন
────────────────────────────────────────────────────────────────────
বাংলা                        ৫         ১৫         ২  ['বাং', 'লা']
বিদ্যা                        ৬         ১৮         ২  ['বি', 'দ্যা']
ক্ষমা                        ৫         ১৫         ২  ['ক্ষ', 'মা']
স্বাধীনতা                     ৯         ২৭         ৪  ['স্বা', 'ধী', 'ন', 'তা']
বিশ্ববিদ্যালয়                ১৩         ৩৯         ৬  ['বি', 'শ্ব', 'বি', 'দ্যা', 'ল', 'য়']

প্রতিটা শব্দেই code point সংখ্যা grapheme cluster সংখ্যার চেয়ে বেশি — কখনো ১.৫ গুণ, কখনো ২ গুণেরও বেশি। কোনো শব্দেই এই দুটো সমান না, ব্যতিক্রম শুধু সেইসব শব্দ যেখানে কোনো conjunct বা matra নেই (বিরল বাংলায়)।

এটা কী প্রমাণ করে

len() code point গোনে, মানুষের 'অক্ষর' না — একটা manual, নির্ভরযোগ্য virama-ভিত্তিক নিয়ম দিয়ে সেই ফারাকটা সংখ্যায় প্রমাণ করা যায়, কোনো বাইরের লাইব্রেরি ছাড়াই।

EXPERIMENT

NFC/NFD normalization mismatch হাতে-কলমে তৈরি ও ঠিক করুন

Python 3· ১০ মিনিট
import unicodedata

# 'কো' — দুই ভিন্ন representation
nfc = "কো"                                    # ক + ো (precomposed O sign) — ২ code point
nfd = "ক" + "\u09C7" + "\u09BE"               # ক + ে + া (decomposed) — ৩ code point

print("NFC code points:", [hex(ord(c)) for c in nfc])
print("NFD code points:", [hex(ord(c)) for c in nfd])
print("দেখতে (print করে):", nfc, "বনাম", nfd)
print("nfc == nfd?", nfc == nfd)
print("len(nfc):", len(nfc), "  len(nfd):", len(nfd))

print()
print("normalize করার পর:")
print("NFC(nfd) == nfc?", unicodedata.normalize('NFC', nfd) == nfc)
print("NFD(nfc) == nfd?", unicodedata.normalize('NFD', nfc) == nfd)

প্রত্যাশিত আউটপুট:

NFC code points: ['0x995', '0x9cb']
NFD code points: ['0x995', '0x9c7', '0x9be']
দেখতে (print করে): কো বনাম কো
nfc == nfd? False
len(nfc): 2   len(nfd): 3

normalize করার পর:
NFC(nfd) == nfc? True
NFD(nfc) == nfd? True

দুটো string terminal-এ প্রিন্ট করলে হুবহু একই দেখায় (“কো”), কিন্তু raw comparison False — কারণ ভিন্ন code point sequence। Normalize করার পরই সমতা প্রতিষ্ঠিত হয়।

এবার একটা বাস্তবসদৃশ সার্চ-ব্যর্থতা সিমুলেট করুন:

# ধরুন ডেটাবেসে সংরক্ষিত (এক input method থেকে এসেছে) — NFD ফর্মে
stored_name = "শ্যাম" + "\u09C7" + "\u09BE" + "পুর"  # কৃত্রিমভাবে NFD মিশ্রিত

# ব্যবহারকারী সার্চ করছেন (আরেক input method/keyboard থেকে) — NFC ফর্মে
search_query = "শ্যাম" + "কো" + "পুর"  # কৃত্রিমভাবে NFC

print("সরাসরি তুলনা:", stored_name == search_query)
print("NFC-normalize করে তুলনা:",
      unicodedata.normalize('NFC', stored_name) ==
      unicodedata.normalize('NFC', search_query))
সরাসরি তুলনা: False
NFC-normalize করে তুলনা: True

নিয়ম: যেকোনো জায়গায় বাংলা (বা যেকোনো Unicode) টেক্সট তুলনা, সার্চ, বা ডেটাবেসে সংরক্ষণ করার আগে — একটা নির্দিষ্ট normalization form-এ (সাধারণত NFC) রূপান্তর করে নিন, দুই প্রান্তেই সামঞ্জস্যপূর্ণভাবে।

এটা কী প্রমাণ করে

দুইটা visually অভিন্ন বাংলা string ভিন্ন code point sequence দিয়ে গঠিত হতে পারে — normalize না করলে string equality এবং search silently ব্যর্থ হয়।

নিজে বানান

BUILD IT

Grapheme-aware safe_truncate() — বাংলা-নিরাপদ character limit

Python · ●●●○○
  1. আগের experiment-এর bengali_graphemes() ফাংশন পুনর্ব্যবহার করুন
  2. একটা safe_truncate(text, max_graphemes) লিখুন যা grapheme boundary-তে কাটে, code point boundary-তে না
  3. naive s[:n] truncation-এর সাথে পাশাপাশি তুলনা করে corruption দেখান
  4. একটা "Twitter-স্টাইল" character-count validator বানান যা conjunct ভাঙে না
def safe_truncate(text: str, max_graphemes: int, suffix: str = "…") -> str:
    """Grapheme cluster সীমানায় truncate করে — কোনো conjunct বা matra
    কখনো মাঝপথে কাটবে না।"""
    graphemes = bengali_graphemes(text)
    if len(graphemes) \<= max_graphemes:
        return text
    kept = graphemes[:max_graphemes]
    return "".join(kept) + suffix


def naive_truncate(text: str, max_codepoints: int, suffix: str = "…") -> str:
    """তুলনার জন্য — সাধারণ, ভুল পদ্ধতি (অধিকাংশ কোডে এভাবেই লেখা হয়)।"""
    if len(text) \<= max_codepoints:
        return text
    return text[:max_codepoints] + suffix


def count_graphemes(text: str) -> int:
    """Twitter-স্টাইল character-count — code point না, grapheme গোনে।"""
    return len(bengali_graphemes(text))


# ── তুলনা — naive বনাম safe ──────────────────────────────
sample = "আমার সোনার বাংলা, আমি তোমায় ভালোবাসি"

print("মূল টেক্সট:", sample)
print("Grapheme সংখ্যা:", count_graphemes(sample))
print()

for limit in [5, 10, 15]:
    naive = naive_truncate(sample, limit)
    safe = safe_truncate(sample, limit)
    print(f"সীমা {limit:>2} code point/grapheme:")
    print(f"  naive (code point-এ কাটা): {naive!r}")
    print(f"  safe  (grapheme-এ কাটা):   {safe!r}")
    print()

নিজে বাড়ান:

  1. এমন একটা input খুঁজুন (বা তৈরি করুন) যেখানে naive_truncate ঠিক একটা conjunct-এর মাঝখানে কাটে, আর দেখান সেই স্থানে dangling virama তৈরি হচ্ছে
  2. safe_truncate-এ একটা প্যারামিটার যোগ করুন যা suffix-সহ মোট দৈর্ঘ্য max_graphemes-এর মধ্যেই রাখে (এখন suffix যোগ হলে সামান্য বেশি হয়ে যেতে পারে)
  3. এই ফাংশনগুলোকে normalize-first বানান — শুরুতেই unicodedata.normalize('NFC', text) প্রয়োগ করুন, যাতে ইনপুট যেকোনো keyboard/OS থেকে আসুক ফলাফল সামঞ্জস্যপূর্ণ থাকে
  4. মিশ্র বাংলা+ইংরেজি+emoji টেক্সটে test করুন — আপনার bengali_graphemes() অ-বাংলা code point-কে single-code-point cluster হিসেবে ট্রিট করবে (সঠিক আচরণ ASCII-র জন্য, কিন্তু emoji-র multi-code-point sequence-এর জন্য না — সেটা একটা জানা সীমাবদ্ধতা, production-এ regex-এর \X বা Intl.Segmenter ব্যবহার করা উচিত সব script-এর জন্য সাধারণভাবে কাজ করতে)

বাস্তব সিস্টেমে

Grapheme cluster যেখানে সিদ্ধান্ত নেয়

Avro/Bijoy কীবোর্ডের ভিন্ন input sequence। বাংলাদেশে জনপ্রিয় বিভিন্ন কীবোর্ড লেআউট (Avro Phonetic, Bijoy Unicode সংস্করণ, National layout) কখনো কখনো একই দেখতে matra-র জন্য ভিন্ন composition sequence তৈরি করে। ফলে দুইজন ব্যবহারকারীর টাইপ করা “একই” টেক্সট byte-level-এ ভিন্ন হতে পারে — normalize না করা সিস্টেমে সার্চ/মিলানো ব্যর্থ হয়।

সরকারি Bijoy (non-Unicode) ডকুমেন্ট রূপান্তর। পুরনো লেসনে আলোচিত non-Unicode Bijoy এনকোডিং থেকে Unicode-এ রূপান্তরের সময় conjunct আর matra সঠিকভাবে পুনর্গঠন করাটা রূপান্তর টুলগুলোর সবচেয়ে জটিল অংশ — শুধু byte-থেকে-byte ম্যাপিং যথেষ্ট না, সঠিক matra reordering আর virama sequence তৈরি করতে হয়।

Facebook/Instagram/Twitter-এর character-count anomaly। জটিল script (বাংলা, দেবনাগরী, থাই, আরবি) ব্যবহারকারীরা প্রায়ই অভিযোগ করেন post/message-এর character-count প্রত্যাশার চেয়ে ভিন্ন আচরণ করে — কারণ counting logic-টা কখনো code point, কখনো UTF-16 unit গণনা করে, grapheme cluster না। ফলে একই দৈর্ঘ্যের মনে হওয়া দুই বাক্য (একটায় বেশি conjunct, একটায় কম) ভিন্ন “character limit” ব্যবহার করে ফেলে।

পুরনো ফিচার ফোন SMS truncation। কিছু পুরনো মোবাইল হ্যান্ডসেট বাংলা SMS একটা fixed byte সীমায় (যেমন ১৪০ byte, GSM SMS-এর মূল সীমা) কাটত, code point বা grapheme সীমা না মেনে — ফলে বার্তার শেষে ভাঙা conjunct বা অসম্পূর্ণ matra দেখা যেত রিসিভারের স্ক্রিনে।

MySQL/PostgreSQL VARCHAR(n)। ইতিমধ্যে আলোচিত — n সাধারণত code point (বা byte) গণনা করে, grapheme না। বাংলা user-generated content-এর জন্য column length যথেষ্ট বড় রাখা জরুরি, নাহলে সীমার কাছাকাছি থাকা টেক্সট মাঝে মাঝে conjunct-এর মাঝখানে কেটে সংরক্ষিত হতে পারে।

HarfBuzz আর font shaping engine। Unicode শুধু “কোন code point” সংজ্ঞায়িত করে — কীভাবে conjunct আঁকা হবে সেটা font-এর ভেতরের OpenType শেপিং টেবিল (GSUB/GPOS) আর শেপিং ইঞ্জিনের (HarfBuzz — Chrome, Android, GNOME/Linux-এ ব্যবহৃত) দায়িত্ব। একটা ভালো বাংলা font-এ শত শত conjunct ligature rule সংজ্ঞায়িত থাকে; দুর্বল বা পুরনো font-এ conjunct ভুলভাবে (আলাদা আলাদা অক্ষর হিসেবে, ligature ছাড়াই) রেন্ডার হতে পারে, যদিও underlying code point sequence সম্পূর্ণ সঠিক।

ICU লাইব্রেরি (International Components for Unicode)-এর BreakIterator। Google, Apple, প্রায় প্রতিটা বড় software vendor grapheme/word/line boundary detection-এর জন্য ICU-এর উপর নির্ভর করে, কারণ সঠিক Indic conjunct handling নিজে বাস্তবায়ন করা (এই লেসনের manual উদাহরণের চেয়ে অনেক বেশি সম্পূর্ণ ও edge-case-aware ভাবে) যথেষ্ট জটিল একটা প্রকৌশল সমস্যা যা বহু বছরের collective engineering effort প্রতিনিধিত্ব করে।

Text editor cursor movement। আধুনিক, ভালো টেক্সট এডিটর (VS Code, modern browser textarea) grapheme cluster-সচেতন cursor movement বাস্তবায়ন করে — arrow key একবার চাপলে পুরো conjunct পার হয়ে যায়, code point-বাই-code-point না। এটা ঠিক করাটা নন-ট্রিভিয়াল, আর অনেক পুরনো বা কম-পরিশীলিত টুল আজও এই সমস্যায় ভোগে।

যে ভুলগুলো সবাই করে

“বাংলায় len(string) মানেই 'কতগুলো অক্ষর'।”

এই পুরো লেসনের কেন্দ্রীয় ভুল ধারণা। len() (Python-এ) বা .length (JavaScript-এ) code point বা UTF-16 code unit গণনা করে — মানুষের চোখে দেখা “অক্ষর” (grapheme cluster) না।

আমরা দেখেছি “বিদ্যা”-তে len() = 6 কিন্তু grapheme cluster = 2। এই ফারাক conjunct বা matra-যুক্ত প্রায় প্রতিটা বাস্তব বাংলা শব্দে উপস্থিত — এটা কোনো বিরল edge case না, এটা নিয়ম

ব্যবহারিক পরিণতি: character-limit UI, truncation, cursor position হিসাব — এসবের জন্য grapheme cluster-ভিত্তিক গণনা (Intl.Segmenter, regex-এর \X, বা ICU) ব্যবহার করা উচিত, শুধু len()/.length না।

“যুক্তাক্ষরের নিজস্ব একটা Unicode code point আছে।”

প্রায় সবসময় ভুল। ক্ষ, জ্ঞ-এর মতো পরিচিত যুক্তাক্ষরও কোনো dedicated code point নেই — এগুলো সবসময় ৩টা (বা তার বেশি) code point-এর sequence (consonant + virama + consonant) হিসেবে সংরক্ষিত, আর রেন্ডারিং ইঞ্জিন রানটাইমে সেগুলোকে ligature-এ রূপান্তর করে দেখায়।

এই ভুল ধারণাটা প্রায়ই আসে কারণ পুরনো, non-Unicode Bangla font (যেমন Bijoy-এর ANSI যুগের সংস্করণ) সত্যিই কিছু যুক্তাক্ষরকে একটা single byte value হিসেবে ট্রিট করত — কিন্তু সেটা Unicode না, সেটা একটা font-নির্দিষ্ট hack (আগের লেসনে আলোচিত)। Unicode Bengali block-এ এই ধরনের shortcut নেই।

“NFC normalize করলে সব 'দেখতে একই রকম' string স্বয়ংক্রিয়ভাবে সমান হয়ে যায়।”

আংশিক সত্য, কিন্তু বিপজ্জনক একটা সরলীকরণ। NFC normalization শুধু সেই সব sequence-কে একত্র করে যেগুলো Unicode-এ আনুষ্ঠানিকভাবে canonically equivalent (যেমন আমাদের = + উদাহরণ)।

কিন্তু দুইটা string দেখতে একই রকম হওয়ার আরো কারণ থাকতে পারে যেগুলো canonical equivalence না — যেমন সম্পূর্ণ ভিন্ন consonant sequence যা কোনো নির্দিষ্ট font-এ কাকতালীয়ভাবে একই রকম glyph তৈরি করে (font rendering-এর quirk, Unicode স্তরে সংজ্ঞায়িত সমতা না)। এই ধরনের ক্ষেত্রে NFC normalization কোনো সাহায্য করবে না — string দুটো normalize করার পরও ভিন্নই থাকবে, কারণ Unicode standard-এর দৃষ্টিতে তারা সত্যিই ভিন্ন sequence।

নিয়ম: normalization নির্দিষ্ট, আনুষ্ঠানিকভাবে সংজ্ঞায়িত equivalence সমাধান করে — এটা “visually যেকোনো কিছু একই রকম দেখতে হলেই মিলিয়ে দেবে” এমন কোনো জাদুকরী সমাধান না।

“Grapheme cluster একটা স্থির, সার্বজনীনভাবে সম্মত সংজ্ঞা।”

এই লেসনেই আমরা দেখেছি এটা ভুল — Unicode 15.0-এর আগে ও পরে virama-linked conjunct-এর grapheme cluster boundary সংজ্ঞা বদলে গেছে। যা আগে দুইটা আলাদা cluster ছিল (ক্ষ-কে {ক,্} + {ষ}), তা পরে একটা cluster হয়ে গেছে।

এর মানে একই কোড, ভিন্ন Unicode ডেটাবেস সংস্করণে চালালে, ভিন্ন ফলাফল দিতে পারে — একটা বিরল কিন্তু বাস্তব “সফটওয়্যার সংস্করণ নির্ভর আচরণ” bug class। এটা মনে রাখা জরুরি যখন আপনি dependency আপগ্রেড করছেন এমন একটা সিস্টেমে যা Indic script grapheme segmentation-এর উপর নির্ভরশীল — আচরণ নীরবে বদলে যেতে পারে।

বুঝেছেন কি না দেখুন

1

“স্বাধীনতা” (independence) শব্দটার code point sequence: স ্ ব া ধ ী ন ত া (৯টা code point)। হাতে-কলমে গণনা করুন এর grapheme cluster সংখ্যা এবং প্রতিটা cluster কী কী code point দিয়ে গঠিত তা দেখান।

প্রয়োগ

আগে শেখা নিয়ম প্রয়োগ করি — প্রতিটা স্বাধীন consonant (আগে virama নেই) নতুন cluster শুরু করে; matra/virama/virama-অনুসারী consonant আগেরটায় যুক্ত থাকে।

স   →  নতুন cluster শুরু: "স"
্   →  virama যুক্ত: "স্"
ব   →  virama-র পরে — conjunct: "স্ব"       ← cluster ১ (এখনো শেষ হয়নি)
া   →  matra যুক্ত: "স্বা"                  ← cluster ১ সম্পূর্ণ

ধ   →  নতুন cluster (আগে virama নেই): "ধ"
ী   →  matra যুক্ত: "ধী"                    ← cluster ২ সম্পূর্ণ

ন   →  নতুন cluster: "ন"                    ← cluster ৩ (কোনো matra/virama অনুসরণ করেনি)

ত   →  নতুন cluster: "ত"
া   →  matra যুক্ত: "তা"                    ← cluster ৪ সম্পূর্ণ

ফলাফল: ৪টা grapheme cluster — “স্বা”, “ধী”, “ন”, “তা”।

এটা মিলে যায় শব্দটার স্বাভাবিক উচ্চারণ-বিভাজনের সাথেও — “sbā-dhī-no-tā” (শ্বা-ধী-ন-তা), চারটা syllable-এর মতো অনুভূতি দেয়, যদিও প্রযুক্তিগতভাবে grapheme cluster আর phonetic syllable ঠিক একই সংজ্ঞা না (তারা প্রায়ই মিলে যায়, কিন্তু কাকতালীয়ভাবে, নিশ্চয়তা হিসেবে না)।

সারাংশ: ৯ code point, ২৭ UTF-8 byte (প্রতিটা ৩ byte), কিন্তু মাত্র ৪ grapheme cluster — code point সংখ্যা grapheme সংখ্যার দ্বিগুণেরও বেশি

2

Unicode কেন প্রতিটা সম্ভাব্য যুক্তাক্ষরকে (যেমন ক্ষ, জ্ঞ) তার নিজস্ব একক code point দেয়নি — Korean Hangul syllable যেভাবে প্রতিটা precomposed করে আলাদা code point পেয়েছে? দুটো script-এর মধ্যে কী পার্থক্য এই ভিন্ন নকশা সিদ্ধান্তের কারণ?

যুক্তি

মূল পার্থক্য: combinatorics-এর আকার আর নিয়মিততা।

Korean Hangul-এ: প্রতিটা syllable block একটা নির্দিষ্ট, সীমিত প্যাটার্নে গঠিত — একটা initial consonant (১৯টা সম্ভাবনা) + একটা vowel (২১টা) + একটা ঐচ্ছিক final consonant (২৮টা, একটা “কিছুই না” সহ)। মোট সম্ভাব্য সংমিশ্রণ: 19 × 21 × 28 = 11,172। এই সংখ্যাটা স্থির ও পূর্বনির্ধারিত — কোনো recursive nesting সম্ভব না (একটা syllable-এ তিনটার বেশি component থাকতে পারে না)। Unicode তাই সবগুলো ১১,১৭২টা precomposed syllable-কেই একটা continuous range-এ (U+AC00–U+D7A3) code point দিয়ে দিয়েছে — ব্যবহারিক, কারণ সংখ্যাটা যথেষ্ট ছোট আর সীমানা স্পষ্ট।

বাংলা (এবং অন্যান্য Brahmic script)-এ: conjunct গঠন মৌলিকভাবে recursive আর open-ended। দুই-consonant conjunct (ক্ষ-এর মতো) ছাড়াও তিন-consonant conjunct সম্ভব (যেমন ক্ষ্ণ-এর ধরনের নির্মাণ), আর তাত্ত্বিকভাবে আরো দীর্ঘ chain বাধাহীন। সাথে প্রতিটা conjunct-এর পরে আবার matra যোগ হতে পারে, ফলে সংমিশ্রণ সংখ্যা practically unbounded — কোনো ছোট, স্থির সীমা নেই যা আগে থেকে enumerate করা যায়।

ফলাফল: Hangul-এর “precompose সবকিছু” পদ্ধতি বাংলায় কাজ করবে না — হয় সীমাহীন code point দরকার হতো (অসম্ভব, Unicode-এর নিজস্ব সীমার মধ্যে আঁটবে না), অথবা কোনো একটা arbitrary কাটঅফ বেছে নিতে হতো (যা ভবিষ্যতের কোনো বৈধ কিন্তু অসাধারণ conjunct-কে representable-ই করতে দিত না)।

তাই বাংলা/দেবনাগরী-জাতীয় script-এর জন্য Unicode-এর পছন্দ: শুধু মৌলিক ইউনিট (consonant, vowel sign, virama) এনকোড করো, composition-এর দায়িত্ব রেন্ডারিং ইঞ্জিনকে দাও। এটা compositional নকশা — সসীম building block দিয়ে সীমাহীন সংমিশ্রণ প্রকাশযোগ্য, ঠিক যেমন আপনি সসীম শব্দ দিয়ে সীমাহীন বাক্য বানাতে পারেন।

সাধারণ নীতি: যখনই সম্ভাব্য সংমিশ্রণ সংখ্যা সীমাহীন বা খুব বড় এবং recursively গঠিত, “compose base unit” নকশা বেছে নেওয়া হয় (বাংলা, দেবনাগরী)। যখন সংমিশ্রণ সীমিত ও predictable, “precompose সবকিছু” ব্যবহারিক থাকে (Hangul, এবং আংশিকভাবে কিছু Latin accented letter যেমন é যার নিজস্ব code point আছে এবং একটা decomposed বিকল্পও আছে — দুই জগতের মিশ্রণ)।

3

নিচের কোডটা “স্বাধীনতা” শব্দে naive s[:5] truncation প্রয়োগ করে। ফলাফল কী হবে, আর কেন সেটা visually সমস্যাযুক্ত?

word = "স্বাধীনতা"
print(word[:5])
প্রয়োগ

Code point sequence মনে করুন: স(০) ্(১) ব(২) া(৩) ধ(৪) ী(৫) ন(৬) ত(৭) া(৮) — index ০ থেকে ৮।

word[:5] মানে index ০ থেকে ৪ পর্যন্ত (৫টা code point) — অর্থাৎ স ্ ব া ধ

>>> word[:5]
'স্বাধ'

এটা কেন সমস্যাযুক্ত: কেটে ফেলার বিন্দুটা পড়েছে cluster ২-এর মাঝখানে। মনে করুন cluster বিভাজন ছিল: cluster ১ = “স্বা” (index ০–৩), cluster ২ = “ধী” (index ৪–৫)। Truncation index ৫-এ থামছে, মানে cluster ২-এর প্রথম code point (, index ৪) আছে কিন্তু তার matra (, index ৫) নেই — কাটা পড়েছে ঠিক conjunct/matra জোড়ার মাঝখানে।

ফলাফল স্ট্রিং "স্বাধ" — এখানে তার matra ছাড়া একা রয়ে গেছে, যা মূল শব্দের সেই অংশের চেয়ে ভিন্ন অর্থ/উচ্চারণ বহন করে ( একা মানে inherent “অ” স্বর সহ, কিন্তু মূল শব্দে ওখানে ধী ছিল — “ঈ” স্বর সহ)। এটা কোনো “প্রায় ঠিক” truncation না — এটা শব্দের একটা অংশকে ভুলভাবে পাল্টে দিচ্ছে, শুধু ছোট করছে না।

যদি কাটা বিন্দুটা virama-র ঠিক পরে পড়ত (যেমন word[:2] = "স্"), ফলাফল আরো খারাপ হতো — একটা dangling virama দিয়ে string শেষ হতো, যা কোনো বৈধ, সম্পূর্ণ consonant বা conjunct বোঝায় না, আর অনেক রেন্ডারার-এ visually ভাঙা দেখাবে (একটা বিচ্ছিন্ন হসন্ত চিহ্ন ঝুলে থাকতে পারে)।

সঠিক ফলাফল হওয়া উচিত ছিল (grapheme-aware truncation দিয়ে, ২টা grapheme cluster রাখলে): "স্বাধী" — cluster ১ + cluster ২ সম্পূর্ণ, কোনো আংশিক কাটা নেই।

4

আপনি এই প্ল্যাটফর্মের মতোই একটা সাইটে একটা “মন্তব্য” ফিচার বানাচ্ছেন, যেখানে max ২৮০-গ্রাফিম সীমা (Twitter-স্টাইল) থাকবে — বাংলা, ইংরেজি, আর emoji মিশ্রিত টেক্সটে কাজ করতে হবে। আপনি কি এই লেসনের manual bengali_graphemes() ফাংশন ব্যবহার করবেন production-এ, নাকি অন্য কিছু? আপনার সিদ্ধান্তের যুক্তি দিন।

ডিজাইন

সিদ্ধান্ত: production-এ manual bengali_graphemes() ব্যবহার করা উচিত না — একটা standard, script-general grapheme segmentation library ব্যবহার করা উচিত (JavaScript-এ Intl.Segmenter, Python-এ regex মডিউলের \X, বা ICU-ভিত্তিক binding)।

কারণ:

১. Script-নির্দিষ্টতা একটা মৌলিক সীমাবদ্ধতা। এই লেসনের ফাংশনটা শুধু বাংলা code point-এর নিয়ম জানে। মন্তব্যে যদি হিন্দি, তামিল, আরবি, বা থাই টেক্সট আসে (আর একটা বহুভাষিক প্ল্যাটফর্মে এটা অবশ্যম্ভাবী), সেই script-গুলোর নিজস্ব conjunct/matra নিয়ম আছে যা এই ফাংশন জানে না — ভুল ফলাফল দেবে বা crash করবে।

২. Emoji sequence সম্পূর্ণ আলাদা সমস্যা। একটা flag emoji, বা skin-tone modifier-যুক্ত emoji, একাধিক code point-এর সমন্বয়ে একটা single grapheme cluster — এই ফাংশন সেটা জানে না, প্রতিটা code point-কে আলাদা cluster ভাববে। মিশ্র বাংলা+emoji টেক্সটে এই ফাংশন ভুল গণনা দেবে।

৩. Unicode সংস্করণ maintenance-এর বোঝা। এই লেসনেই দেখেছি grapheme cluster-এর নিয়ম Unicode সংস্করণে সংস্করণে বদলাতে পারে (GB9c-এর মতো সংশোধন)। একটা standard library maintainer-রা এই আপডেট ট্র্যাক করেন; নিজের হাতে লেখা কোডে আপনাকে প্রতিটা Unicode সংস্করণ পরিবর্তন নিজে ট্র্যাক করে কোড আপডেট করতে হবে — ব্যবহারিক না বড় স্কেলে।

৪. Edge case-এর সংখ্যা বিশাল। ZWJ (zero-width joiner) sequence, variation selector, combining mark stacking, bidirectional text-এর সাথে interaction — একটা সম্পূর্ণ compliant UAX #29 বাস্তবায়ন হাজার হাজার লাইন টেস্ট কেস কভার করে। এই লেসনের ফাংশনটা একটা শিক্ষামূলক সরলীকরণ, production-grade বাস্তবায়ন না।

তাহলে এই লেসনের কোডের মূল্য কী? এটা শেখার জন্য অমূল্য — এটা mechanism-টা transparent করে দেখায় (কেন এবং কীভাবে conjunct একসাথে থাকে), যা একটা black-box library call করলে বোঝা যেত না। কিন্তু “বোঝার জন্য নিজে বানানো” আর “production-এ ব্যবহারের জন্য প্রস্তুত” — এই দুইটা ভিন্ন মান, আর এই পার্থক্যটা চেনা একজন প্রকৌশলীর গুরুত্বপূর্ণ দক্ষতা।

Production সুপারিশ:

// JavaScript — script-general, standard-compliant
const segmenter = new Intl.Segmenter('bn', { granularity: 'grapheme' });
const graphemes = [...segmenter.segment(text)].map(s => s.segment);
console.log(graphemes.length);  // সঠিক grapheme count, যেকোনো script/emoji-সহ
# Python — regex মডিউল, ICU ডেটার উপর নির্ভরশীল
import regex
graphemes = regex.findall(r'\X', text)
5

একটা বাংলা সার্চ ফিচার — ব্যবহারকারী একটা নাম টাইপ করে সার্চ করছেন, কিন্তু ডেটাবেসে সেই নামটা আছে তবু কোনো ফলাফল আসছে না, এমনকি ব্যবহারকারী স্ক্রিনে টাইপ করা আর ডেটাবেসের টেক্সট হুবহু একই রকম দেখতে। এই বাগের সবচেয়ে সম্ভাব্য কারণ কী, আর সমাধানের সঠিক জায়গা কোথায়?

যুক্তি

সবচেয়ে সম্ভাব্য কারণ: NFC/NFD normalization mismatch।

যুক্তি-ধারা:

১. যেহেতু স্ক্রিনে টেক্সট হুবহু একই রকম দেখতে, সমস্যাটা visual না — কোনো ভিন্ন অক্ষর টাইপ হয়নি। এটা string-এর internal representation-এ পার্থক্যের দিকে ইঙ্গিত করে, visual পার্থক্যে না।

২. বাংলায় (এই লেসনে দেখা = + উদাহরণের মতো) কিছু matra-র দুইটা canonically-equivalent representation থাকতে পারে — precomposed (NFC) আর decomposed (NFD)। এই দুইটা visually সবসময় অভিন্ন, কিন্তু byte/code-point হিসেবে ভিন্ন।

৩. যদি ডেটাবেসে সংরক্ষিত নামটা এক normalization form-এ (ধরুন NFD, কারণ যে input method দিয়ে মূলত এন্ট্রি করা হয়েছিল সেটা decomposed sequence তৈরি করেছিল) থাকে, আর ব্যবহারকারীর সার্চ query আরেক form-এ (NFC, কারণ তার বর্তমান কীবোর্ড/OS ভিন্নভাবে টাইপ করে) আসে — একটা সরাসরি = বা LIKE comparison কখনো মিলবে না, যদিও উভয় string ভিজ্যুয়ালি অভিন্ন।

সমাধানের সঠিক জায়গা — দুইটা বিন্দুতেই, একটায় না:

  • Write path (ডেটা সংরক্ষণের সময়): যেকোনো user input সংরক্ষণের আগে normalize করুন (unicodedata.normalize('NFC', input))।
  • Read/query path (সার্চের সময়): সার্চ query-ও ঠিক একই normalization form-এ রূপান্তর করুন, তুলনা করার আগে।

শুধু একটা প্রান্তে ঠিক করলে যথেষ্ট না — যদি existing ডেটাবেসে আগে থেকেই মিশ্র-normalization ডেটা থাকে (পুরনো এন্ট্রি, নতুন normalize করা এন্ট্রি মিশে), একটা ব্যাচ migration প্রয়োজন হবে পুরনো সব রেকর্ড একই form-এ রূপান্তর করতে, নাহলে নতুন কোড ঠিক থাকলেও পুরনো ডেটা এখনো mismatch থেকে যাবে।

অতিরিক্ত প্রতিরক্ষা — ডেটাবেস collation স্তরে: কিছু ডেটাবেস সিস্টেম Unicode-aware collation সমর্থন করে যা normalization-নিরপেক্ষ তুলনা করতে পারে সার্ভার-সাইডে, অ্যাপ্লিকেশন কোডে explicit normalize করা ছাড়াই — কিন্তু এটার উপর সম্পূর্ণ নির্ভর করা ঝুঁকিপূর্ণ, কারণ সব ডেটাবেস/collation এই সমর্থন সমানভাবে বা সঠিকভাবে দেয় না। অ্যাপ্লিকেশন-স্তরে explicit normalize করাটাই সবচেয়ে নির্ভরযোগ্য, প্রেডিক্টেবল অভ্যাস।

সাধারণ নীতি (এই লেসনের মূল শিক্ষা): “visually অভিন্ন” আর “string-এ অভিন্ন” দুইটা আলাদা প্রশ্ন যখনই canonical equivalence সম্ভব — normalize-first একটা universal নিয়ম হওয়া উচিত যেকোনো তুলনা, সার্চ, বা storage অপারেশনের আগে, বিশেষত এমন যেকোনো script-এ কাজ করার সময় যেখানে matra composition একাধিক বৈধ ফর্মে সম্ভব।

এরপর কী

তিনটা লেসন মিলিয়ে আমরা একটা সম্পূর্ণ ট্রিলজি দেখলাম: ASCII-র 7-bit জগত আর তার code-page বিশৃঙ্খলা, তারপর Unicode/UTF-8 যা “কোন byte কোন code point” প্রশ্নটা বিশ্বজনীনভাবে সমাধান করল, আর অবশেষে বাংলার মতো script-এ “code point ≠ মানুষের অক্ষর” গভীরতর সত্যটা।

মূল সূত্রটা মনে রাখুন: সংরক্ষণের ইউনিট (byte, code point) আর উপলব্ধির ইউনিট (grapheme cluster, “অক্ষর”) ভিন্ন স্তরের জিনিস — আর এই স্তরবিন্যাস ভুলে গেলেই bug তৈরি হয়, চাই সেটা mojibake হোক, overlong-encoding exploit হোক, বা conjunct-ভাঙা backspace হোক।

এই মডিউলের বাকি অংশে আমরা এখন data representation-এর অন্য দিকে ফিরব — কীভাবে multi-byte সংখ্যা (integer, float) মেমরিতে সংরক্ষিত হয় যখন একাধিক byte জড়িত থাকে। Endianness — একই সংখ্যা little-endian আর big-endian মেশিনে সম্পূর্ণ ভিন্ন byte ক্রমে সংরক্ষিত হয়, ঠিক যেমন এই লেসনে আমরা দেখেছি UTF-16-এ BOM কেন endianness নির্দেশ করতে দরকার হয়। তারপর serialization — কীভাবে জটিল, structured ডেটা (যেমন এই লেসনের বাংলা string বা একটা সম্পূর্ণ object) byte-এর একটা linear sequence-এ রূপান্তরিত হয়ে ডিস্কে বা নেটওয়ার্কে ভ্রমণ করে, আর ফিরে আসার সময় ঠিকঠাক পুনর্গঠিত হয়।

আরও পড়ুন