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

Unicode ও UTF-8 — এক Character, বহু Byte

Unicode and UTF-8

Unicode প্রতিটা অক্ষরকে একটা স্থায়ী সংখ্যা (code point) দেয়, আর UTF-8/16/32 হলো সেই সংখ্যাকে byte-এ লেখার তিনটা ভিন্ন নিয়ম — যাদের মধ্যে UTF-8-এর self-synchronizing, ASCII-compatible, ও space-efficient নকশাই আজ ইন্টারনেটের ভিত্তি।

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

  • Code point (কোন অক্ষর) আর encoding (কীভাবে byte-এ রাখা হবে)-এর মধ্যে পার্থক্য স্পষ্টভাবে ব্যাখ্যা করতে পারবেন
  • UTF-32, UTF-16, UTF-8-এর নকশা ও trade-off তুলনা করতে পারবেন
  • UTF-8-এর leading-bit স্কিম হাতে-কলমে encode/decode করতে পারবেন, এবং কেন এটা self-synchronizing তা ব্যাখ্যা করতে পারবেন
  • UTF-8 কেন একটা injective mapping হতে বাধ্য, আর overlong encoding কেন নিষিদ্ধ তা প্রমাণ করতে পারবেন
  • UTF-16 surrogate pair-এর প্রক্রিয়া, আর এটা কীভাবে বাস্তব bug (String.length) তৈরি করে তা চিনতে পারবেন
  • BOM কী, কেন UTF-8-এ প্রয়োজনীয় নয়, আর কোথায় এটা সমস্যা তৈরি করে তা বলতে পারবেন

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

আগে এটা বুঝি

গত লেসনের শেষ দৃশ্যটা মনে করুন: একই byte 0xE0, ছয়টা ভিন্ন code page-এ ছয়টা ভিন্ন অক্ষর। একটা byte পাঠাতে হলে আপনাকে আগে থেকে জানাতে হতো কোন code page — আর সেই তথ্যটা ফাইলের ভেতরে কোথাও লেখা থাকত না।

১৯৮৭-৮৮ সালে Xerox আর Apple-এর কিছু ইঞ্জিনিয়ার (Joe Becker, Lee Collins, Mark Davis) একটা মৌলিক প্রশ্ন তুললেন: code page সিস্টেমটাই যদি ভুল ভিত্তির উপর দাঁড়ানো হয়?

সমস্যাটা এই ছিল যে প্রতিটা code page “কোন অক্ষর” আর “কোন byte” প্রশ্ন দুটোকে একসাথে সমাধান করার চেষ্টা করত — একটা একক ১-বাইট টেবিলে। বাংলা, চীনা, আরবি, দেবনাগরী — সব মিলিয়ে লক্ষাধিক অক্ষর মাত্র ২৫৬টা slot-এ আঁটার কোনো উপায় নেই।

Unicode-এর মূল অন্তর্দৃষ্টি: প্রশ্ন দুটোকে আলাদা করে ফেলুন

১. কোন অক্ষর? — প্রতিটা অক্ষরকে একটা স্থায়ী, বিশ্বজনীন সংখ্যা দিন, byte-এর কথা চিন্তা না করেই। এই সংখ্যাটাকে বলা হয় code point

২. কীভাবে byte-এ রাখব? — সেই সংখ্যাটা ডিস্কে/নেটওয়ার্কে কীভাবে byte sequence হিসেবে লেখা হবে, সেটা একটা আলাদা সিদ্ধান্ত — একটা encoding

এই একটা বিভাজনই আজকের সব টেক্সট প্রসেসিং-এর ভিত্তি। এই লেসনে আমরা দেখব code point space কীভাবে গঠিত, আর তিনটা প্রধান encoding — UTF-32, UTF-16, UTF-8 — এই একই code point-গুলোকে কীভাবে ভিন্নভাবে byte-এ রূপান্তর করে, আর কেন তাদের মধ্যে UTF-8 জিতে গেছে।

মূল ধারণা

Code point — অক্ষরের স্থায়ী পরিচয়

Unicode প্রতিটা “অক্ষর”-কে (প্রকৃতপক্ষে প্রতিটা abstract character, symbol, emoji, এমনকি কিছু formatting নির্দেশও) একটা integer বরাদ্দ করে, যাকে লেখা হয় U+ prefix দিয়ে hexadecimal-এ:

U+0041   'A'  (LATIN CAPITAL LETTER A)
U+0995   'ক'  (BENGALI LETTER KA)
U+1F600  '😀'  (GRINNING FACE)

Code point space-এর সর্বোচ্চ সীমা U+10FFFF — অর্থাৎ মোট 0x110000 = 1,114,112টা সম্ভাব্য code point (প্রায় ১১ লক্ষ)। এই সংখ্যাটা কেন এত নির্দিষ্ট, সেটা UTF-16-এর সীমাবদ্ধতা থেকে আসে (নিচে দেখুন) — একটা ঐতিহাসিক decision যা এখন পুরো standard-কে আটকে রেখেছে।

Plane — code point space-এর ১৭টা ভাগ

পুরো space ১৭টা “plane”-এ ভাগ করা, প্রতিটায় 0x10000 = 65,536টা code point:

Planeপরিসরনামকী থাকে
0U+0000–U+FFFFBMP (Basic Multilingual Plane)প্রায় সব আধুনিক ভাষা — Latin, বাংলা, দেবনাগরী, CJK, Cyrillic, Arabic
1U+10000–U+1FFFFSMP (Supplementary Multilingual)বেশিরভাগ emoji, প্রাচীন লিপি (Egyptian hieroglyph, Linear B)
2U+20000–U+2FFFFSIP (Supplementary Ideographic)বিরল/ঐতিহাসিক CJK ideograph
3U+30000–U+3FFFFTIP (Tertiary Ideographic)আরো CJK সম্প্রসারণ
4–13(অব্যবহৃত)ভবিষ্যতের জন্য সংরক্ষিত
14U+E0000–U+EFFFFSSP (Supplementary Special-purpose)language tag, variation selector
15–16U+F0000–U+10FFFFPrivate Use Area (সম্প্রসারিত)সংস্থা-নির্দিষ্ট কাস্টম ব্যবহার
Unicode-এর ১৭টা plane — প্রায় সবকিছু (বাংলা সহ) Plane 0-এই থাকে।

BMP-ই মূল খেলার মাঠ — বাংলা (U+0980–U+09FF), ইংরেজি, দেবনাগরী, আরবি, বেশিরভাগ CJK — সবই Plane 0-এ। শুধু emoji-র মতো তুলনামূলক নতুন সংযোজনগুলো BMP-র বাইরে, Plane 1-এ পড়ে। এই একটা পার্থক্যই পরে UTF-16-এর surrogate pair সমস্যার কেন্দ্রে থাকবে।

তিনটা এনকোডিং — একই সংখ্যা, ভিন্ন byte

UTF-32 — সরল কিন্তু অপচয়ী

প্রতিটা code point ঠিক ৪ byte-এ, সরাসরি সেই integer-এর binary representation:

'A'  U+0041   → 00 00 00 41
'ক'  U+0995   → 00 00 09 95
'😀'  U+1F600  → 00 01 F6 00

সুবিধা: fixed-width — string[i] মানেই i × 4 byte offset-এ লাফ দেওয়া, O(1) indexing। কোনো complex parsing দরকার নেই।

অসুবিধা: বিশাল অপচয়। ইংরেজি টেক্সটে প্রতিটা অক্ষর মাত্র ৭ bit-এ আঁটত (ASCII-তে), কিন্তু UTF-32-এ সেই একই অক্ষর ৩২ bit নেয় — ৪ গুণ জায়গা, বেশিরভাগ শূন্য bit দিয়ে ভরা। ব্যবহারিকভাবে খুব কম জায়গায় ব্যবহৃত হয় — মূলত internal processing-এ (কিছু programming language-এর ভেতরের representation), কখনো network বা disk storage-এ নয়।

UTF-16 — মাঝামাঝি, কিন্তু একটা লুকানো জটিলতা

প্রতিটা BMP code point (U+0000–U+FFFF) ঠিক ২ byte-এ:

'A'  U+0041   → 00 41
'ক'  U+0995   → 09 95

কিন্তু BMP-র বাইরের code point (U+10000 এবং তার উপরে) দুই byte-এ আঁটে না — 0x10000 ইতিমধ্যেই ২ byte-এর সীমা (0xFFFF) ছাড়িয়ে গেছে। সমাধান: surrogate pair — দুইটা ২-byte “code unit” জোড়া লাগিয়ে একটা supplementary-plane code point বোঝানো।

Unicode বিশেষভাবে দুইটা রেঞ্জ শুধু এই কাজের জন্য সংরক্ষণ করেছে — এগুলো কখনো নিজে থেকে কোনো অক্ষর বোঝায় না:

রেঞ্জনামসংখ্যা
U+D800–U+DBFFHigh surrogate১০২৪
U+DC00–U+DFFFLow surrogate১০২৪

একটা high + একটা low surrogate মিলে একটা supplementary code point এনকোড করে:

code point=০x10000+(high০xD800)×০x400+(low০xDC00)\text{code point} = \text{০x10000} + (\text{high} - \text{০xD800}) \times \text{০x400} + (\text{low} - \text{০xDC00})

১০২৪×১০২৪=,০৪৮,৫৭৬টা সম্ভাব্য supplementary code point১০২৪ \times ১০২৪ = ১,০৪৮,৫৭৬ \text{টা সম্ভাব্য supplementary code point}

আর ঠিক এই সংখ্যাটাই কেন Unicode-এর সর্বোচ্চ সীমা U+10FFFF-এ থেমেছে — 0x10000 (BMP-এর উপরে) + 0x100000 (surrogate pair দিয়ে পৌঁছানো যায় এমন সর্বোচ্চ) = 0x10FFFFUTF-16-এর সীমাবদ্ধতাই পুরো Unicode standard-এর সর্বোচ্চ code point নির্ধারণ করে দিয়েছে — একটা চমৎকার উদাহরণ যে একটা “শুধু encoding” সিদ্ধান্ত কীভাবে পুরো architecture-কে স্থায়ীভাবে সীমাবদ্ধ করে দেয়।

উদাহরণ — 😀 (U+1F600) এনকোড করা:

code point − 0x10000 = 0x1F600 − 0x10000 = 0xF600

high = 0xD800 + (0xF600 >> 10)        = 0xD800 + 0x3D = 0xD83D
low  = 0xDC00 + (0xF600 & 0x3FF)      = 0xDC00 + 0x200 = 0xDE00

UTF-16 bytes: D8 3D DE 00   (৪ byte — high surrogate + low surrogate)

UTF-8 — variable-length, কিন্তু নিখুঁতভাবে ডিজাইন করা

এখানেই আসল কৌশল। Ken Thompson আর Rob Pike ১৯৯২ সালে (Bell Labs-এ, Plan 9 অপারেটিং সিস্টেমের জন্য, নিউ জার্সির এক রেস্তোরাঁয় একটা placemat-এ ডিজাইনের প্রথম খসড়া আঁকা হয়েছিল বলে কথিত) একটা encoding বানালেন যেটা byte সংখ্যা পরিবর্তনশীল রাখে — ছোট code point কম byte, বড় code point বেশি byte।

নিয়মটা leading bit pattern দিয়ে বোঝা যায়:

Code point পরিসরbyte সংখ্যাBinary প্যাটার্ন
U+0000 – U+007F0xxxxxxx
U+0080 – U+07FF110xxxxx 10xxxxxx
U+0800 – U+FFFF1110xxxx 10xxxxxx 10xxxxxx
U+10000 – U+10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
UTF-8-এর বিট-স্তরের নিয়ম — leading byte-এর উপরের bit-গুলোই বলে দেয় মোট কতগুলো byte আসবে।

তিনটা নিয়ম মুখস্থ রাখলেই যথেষ্ট:

১. প্রথম byte-এর leading 1-এর সংখ্যা = মোট byte সংখ্যা। 0... = ১ byte। 110... = ২ byte। 1110... = ৩ byte। 11110... = ৪ byte।

২. প্রতিটা continuation byte 10xxxxxx দিয়ে শুরু হয় — উপরের দুই bit সবসময় 10, বাকি ৬ bit ডেটা বহন করে।

৩. x-চিহ্নিত bit-গুলো code point-এর বিট, বাম থেকে ডানে ভরাট করে।

উদাহরণ — বাংলা ‘ক’ (U+0995) এনকোড করা, ধাপে ধাপে:

code point = 0x0995 = 0000 1001 1001 0101   (16 বিট)

এই পরিসর U+0800–U+FFFF-এর মধ্যে → ৩ byte দরকার
৩-byte প্যাটার্নে ১৬ বিট লাগে ঠিক (4+6+6=16), তাই পুরো ১৬ বিটই ব্যবহার হবে

বিট ভাগ করুন ৪-৬-৬:
  0000  100110  010101
   │       │        │
   ▼       ▼        ▼
byte1 = 1110 + 0000  = 1110 0000 = 0xE0
byte2 = 10   + 100110 = 1010 0110 = 0xA6
byte3 = 10   + 010101 = 1001 0101 = 0x95

ফলাফল: E0 A6 95
>>> 'ক'.encode('utf-8').hex()
'e0a695'

মিলে গেছে। মজার তথ্য: functions.mdx লেসনে ঠিক এই একই উদাহরণ (U+0995 → E0 A6 95) injective mapping-এর উদাহরণ হিসেবে দেখানো হয়েছিল — এখন আপনি জানেন কেন সেই নির্দিষ্ট byte তিনটাই আসে।

U+0041 (A)      → 41                     ১ byte  — ASCII-র সাথে অভিন্ন!
U+00E9 (é)      → C3 A9                  ২ byte
U+0995 (ক)      → E0 A6 95               ৩ byte
U+1F600 (😀)    → F0 9F 98 80            ৪ byte
ASCII, বাংলা, আর emoji — একই স্কিম, বিভিন্ন byte সংখ্যা।

কেন UTF-8 জিতে গেল — তিনটা ডিজাইন সিদ্ধান্তের ফল

১. ASCII compatibility। প্রথম ১২৮ code point-এর UTF-8 encoding ঠিক ASCII-র সাথে byte-এ-byte অভিন্ন। এর মানে পুরনো ASCII-only টুল (grep-এর মতো text tool, C-এর \0-terminated string logic, বহু নেটওয়ার্ক প্রোটোকল) UTF-8 টেক্সটের সাথে কোনো পরিবর্তন ছাড়াই মোটামুটি কাজ করতে পারে — যতক্ষণ তারা শুধু ASCII byte value খুঁজছে (যেমন / বা \n), অন্য byte-কে “অজানা কিন্তু পাস-থ্রু” হিসেবে রেখে দিচ্ছে।

২. Self-synchronization। যেকোনো random byte offset থেকে শুরু করে, শুধু সেই একটা byte দেখে বলে দেওয়া যায় এটা কী ধরনের byte:

Byte-এর উপরের bitমানে
0.......একটা single-byte character-এর সম্পূর্ণ
10......একটা continuation byte — একটা multi-byte sequence-এর মাঝে
110.....একটা ২-byte sequence-এর শুরু
1110....একটা ৩-byte sequence-এর শুরু
11110...একটা ৪-byte sequence-এর শুরু

এর মানে, কোনো stream-এর মাঝখানে “পড়া শুরু” করলেও (যেমন একটা বড় ফাইলে seek করে), আপনি সবসময় পরের character boundary পর্যন্ত এগিয়ে বা পেছনে গিয়ে সহজেই ঠিক জায়গা খুঁজে পেতে পারবেন — শুধু 10xxxxxx না হওয়া পর্যন্ত byte স্কিপ করুন। কিছু পুরনো East Asian multi-byte encoding (Shift-JIS-এর মতো) এই ধর্ম মানে না — একটা মাঝপথের byte দেখে বলা যায় না এটা একটা character-এর প্রথম byte নাকি দ্বিতীয়, ফলে string মাঝপথ থেকে সঠিকভাবে পার্স করা কার্যত অসম্ভব।

৩. Byte-order independence। UTF-8 একটা byte stream, একটা 16-বিট বা 32-বিট word stream নয় — তাই little-endian বনাম big-endian প্রশ্নটাই ওঠে না (পরবর্তী লেসনে endianness বিস্তারিত)। এটাই BOM (byte order mark) নিয়ে UTF-8-এর বিশেষ পরিস্থিতির কারণ, নিচে দেখুন।

ভেতরে কী ঘটছে

যেখানে Unicode/UTF-8-এর নকশা ফাঁদে ফেলে

১. Overlong encoding — একটা ইচ্ছাকৃত নিষেধাজ্ঞা, নিরাপত্তার জন্য

লক্ষ্য করুন: U+0041 (‘A’) কে UTF-8-এর ২-byte প্যাটার্নে জোর করেও এনকোড করা সম্ভব — গাণিতিকভাবে বিটগুলো বসে যায়:

code point 0x41 = 0000000 1000001   (৭ বিট প্রয়োজন)

২-byte প্যাটার্নে (৫+৬=১১ বিট জায়গা) জোর করে বসালে:
byte1 = 110 00000 = 0xC0
byte2 = 10 000001 = 0x81

ফলাফল: C0 81   — এটাও decode করলে U+0041 ('A') দেয়!

কিন্তু সঠিক encoding হলো ১ byte (0x41)। তাহলে C0 81-ও কি বৈধ?

না — RFC 3629 স্পষ্টভাবে নিষিদ্ধ করে। নিয়ম: প্রতিটা code point-এর জন্য সবচেয়ে কম byte সংখ্যার encoding-টাই একমাত্র বৈধ form। একে “overlong encoding” বলা হয়, আর একটা compliant decoder-কে এটা প্রত্যাখ্যান করতেই হবে।

কেন এই নিষেধাজ্ঞা এত গুরুত্বপূর্ণ — injectivity-র প্রশ্ন:

Mathematics-এর function লেসন মনে করুন — একটা encoding lossless হতে হলে injective হতে হবে: দুইটা ভিন্ন input কখনো একই output দিতে পারবে না। কিন্তু এখানে উল্টো সমস্যা — যদি overlong অনুমোদিত হতো, তাহলে একটাই code point (U+0041) দুইটা ভিন্ন byte sequence-এ (41 এবং C0 81) representable হতো। এটা “reverse injectivity” ভাঙে — decode করার সময় mapping তো এখনো ফাংশন থাকে (প্রতিটা byte sequence একটা নির্দিষ্ট code point দেয়), কিন্তু encode করার mapping (code point → byte) আর well-defined/unique থাকে না, কারণ একই input-এর একাধিক বৈধ output হয়ে যায়।

এটা শুধু তাত্ত্বিক পরিচ্ছন্নতার প্রশ্ন নয় — এটা একটা বাস্তব নিরাপত্তা দুর্বলতা তৈরি করে।

মূল শিক্ষা: যখনই একই তথ্যের একাধিক বৈধ representation থাকতে পারে, security validation আর actual interpretation যদি ভিন্ন representation নিয়ে কাজ করে, তাদের মধ্যে ফাঁক তৈরি হয়। এই একই প্যাটার্ন পরের লেসনে normalization (NFC/NFD) আলোচনায় আবার দেখা যাবে।

২. Invalid leading byte — কিছু byte value কখনো বৈধ UTF-8-এ আসতেই পারে না

যেহেতু overlong নিষিদ্ধ, আর ৪-byte encoding সর্বোচ্চ U+10FFFF পর্যন্তই পৌঁছাতে পারে (Unicode-এর সীমা), কিছু leading byte pattern কখনো বৈধ UTF-8-এ দেখা যাবে না:

0xC0, 0xC1        → এগুলো শুধু overlong ২-byte encoding তৈরি করতে পারত (এখন নিষিদ্ধ)
0xF5 – 0xFF       → এগুলো U+10FFFF-এর চেয়ে বড় code point নির্দেশ করত (Unicode সীমার বাইরে)

একটা robust UTF-8 validator শুধু bit pattern-ই না, এই নির্দিষ্ট byte value-গুলোও প্রত্যাখ্যান করার জন্য বিশেষভাবে ডিজাইন করা থাকে।

৩. UTF-16-এর নিজের ব্যাধি — lone surrogate আর WTF-8

Surrogate code point (U+D800U+DFFF) UTF-8-এ কখনো বৈধভাবে এনকোড করা উচিত না — কারণ এগুলো নিজে কোনো অক্ষর নয়, শুধু UTF-16-এর জোড়া বানানোর জন্য সংরক্ষিত। কিন্তু বাস্তবে, একটা “lone surrogate” (জোড়াবিহীন high বা low surrogate) তৈরি হতে পারে যদি একটা string-কে মাঝপথে কেটে ফেলা হয় (ঠিক আগের section-এর "😀".slice(0,1) উদাহরণ)।

Windows filesystem (NTFS) filename UTF-16-এ সংরক্ষণ করে, কিন্তু কখনোই বৈধতা যাচাই করে না — একটা filename-এ lone surrogate থাকতে পারে, যা প্রযুক্তিগতভাবে বৈধ Unicode টেক্সটই না। এই “potentially ill-formed UTF-16” ডেটাকে losslessly হ্যান্ডল করতে প্রকৌশলীরা WTF-8 নামে একটা informal extension তৈরি করেছেন (Rust-এর std::ffi::OsString-এর Windows implementation-এ ব্যবহৃত) — এটা UTF-8-এর মতোই, কিন্তু lone surrogate-কেও এনকোড করতে দেয়, ঠিক overlong-এর মতোই “নিষিদ্ধ কিন্তু বাস্তবে দরকার” একটা কোণা।

৪. BOM — endianness সমস্যার সমাধান, যেটা UTF-8-এর দরকারই নেই

UTF-16/UTF-32-তে প্রতিটা code unit একাধিক byte নিয়ে গঠিত (২ বা ৪), আর সেই byte-গুলো কোন ক্রমে সংরক্ষিত হবে (little-endian না big-endian) সেটা machine-নির্ভর। একই code point U+0041 little-endian UTF-16-এ 41 00, big-endian-এ 00 41 — সম্পূর্ণ ভিন্ন byte sequence।

সমাধান: ফাইলের একদম শুরুতে একটা বিশেষ code point U+FEFF (ZERO WIDTH NO-BREAK SPACE, পরে “BYTE ORDER MARK” হিসেবে repurposed) বসানো:

Little-endian UTF-16-এ U+FEFF  → FF FE
Big-endian UTF-16-এ U+FEFF     → FE FF

যদি reader FF FE দেখে, বোঝে little-endian। FE FF দেখলে big-endian। এভাবে BOM নিজেই তার byte order-এর সংকেত বহন করে।

UTF-8-এ endianness প্রশ্নটাই ওঠে না — UTF-8 একটা byte-oriented encoding, multi-byte word নয়, তাই byte-order ambiguity নেই। তবু U+FEFF-কে UTF-8-এ এনকোড করে (EF BB BF) কখনো কখনো ফাইলের শুরুতে বসানো হয় — শুধু “এই ফাইলটা UTF-8” সংকেত হিসেবে, endianness নির্দেশের জন্য নয়।

একটা 'অক্ষর' থেকে ডিস্কে byte পর্যন্ত — পুরো chain
  1. অক্ষর (মানুষের ধারণা)'ক' — একটা বিমূর্ত ধারণা
  2. Unicode code pointU+0995 — একটা স্থায়ী সংখ্যা, encoding-নিরপেক্ষ
  3. Encoding নির্বাচনUTF-8? UTF-16? প্রোগ্রাম/ফাইল ফরম্যাট ঠিক করে
  4. UTF-8 byte sequenceE0 A6 95 — leading bit স্কিম প্রয়োগ
  5. ডিস্ক/নেটওয়ার্কে raw byteএই byte-গুলোই আসলে সংরক্ষিত/পাঠানো হয়

উদাহরণ

সম্পূর্ণ worked example — তিনটা এনকোডিং পাশাপাশি

’😀’ (U+1F600) — যেহেতু এটা BMP-র বাইরে, তিনটা এনকোডিং-এর মধ্যে পার্থক্যটা সবচেয়ে স্পষ্ট দেখায়:

Code point: U+1F600 (128512 দশমিকে)

UTF-32 (৪ byte, fixed):
  00 01 F6 00

UTF-16 (৪ byte, surrogate pair প্রয়োজন কারণ BMP-র বাইরে):
  D8 3D DE 00
  └─high──┘ └─low───┘

UTF-8 (৪ byte, leading-bit স্কিম):
  F0 9F 98 80
  1111_0000 1001_1111 1001_1000 1000_0000
একই code point, তিনটা এনকোডিং, তিনটা ভিন্ন byte count।

আর এবার ‘A’ (U+0041) — সবচেয়ে সাধারণ ইংরেজি অক্ষর:

UTF-32:  00 00 00 41    (৪ byte — সবসময়ই ৪, অপচয়ী)
UTF-16:  00 41          (২ byte)
UTF-8:   41             (১ byte — ASCII-র সাথে অভিন্ন!)

একটা ইংরেজি-প্রধান document-এ (যেমন এই লেসনের ইংরেজি শব্দগুলো) UTF-8 প্রায় সবসময় সবচেয়ে ছোট। একটা সম্পূর্ণ CJK document-এ (যেখানে প্রতিটা অক্ষর UTF-8-এ ৩ byte কিন্তু UTF-16-এ মাত্র ২), UTF-16 আসলে ছোট হতে পারে — এটাই কেন কিছু সিস্টেম (যেমন Windows-এর internal API, Java-র String) ঐতিহাসিকভাবে UTF-16 বেছে নিয়েছিল।

Content typeUTF-8UTF-16UTF-32
খাঁটি ASCII (ইংরেজি)১ byte/char২ byte/char৪ byte/char
বাংলা/দেবনাগরী৩ byte/char২ byte/char৪ byte/char
CJK (চীনা/জাপানি)৩ byte/char২ byte/char৪ byte/char
Emoji/supplementary৪ byte/char৪ byte/char (surrogate pair)৪ byte/char
Space efficiency তুলনা — টেক্সটের ভাষা অনুযায়ী বিজয়ী বদলায়।

তবু UTF-8 জিতেছে ইন্টারনেট-ব্যাপী, কারণ পৃথিবীর সিংহভাগ ওয়েব ট্রাফিক (HTML markup, JSON key, CSS, JavaScript syntax) এখনো ASCII-প্রধান — এমনকি বাংলা কন্টেন্টের পেজেও \<div class="..."> আর {"key": "value"} -এর মতো syntax ASCII। আর ASCII-compatibility আর self-synchronization-এর সুবিধা byte-দক্ষতার চেয়ে বেশি গুরুত্বপূর্ণ প্রমাণিত হয়েছে।

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

EXPERIMENT

নিজের হাতে UTF-8 এনকোডার/ডিকোডার লিখুন এবং যাচাই করুন

Python 3· ২০ মিনিট
def encode_utf8_one(cp: int) -> bytes:
    """একটা single code point-কে UTF-8 byte-এ রূপান্তর"""
    if cp \< 0x80:
        return bytes([cp])
    elif cp \< 0x800:
        b1 = 0xC0 | (cp >> 6)
        b2 = 0x80 | (cp & 0x3F)
        return bytes([b1, b2])
    elif cp \< 0x10000:
        b1 = 0xE0 | (cp >> 12)
        b2 = 0x80 | ((cp >> 6) & 0x3F)
        b3 = 0x80 | (cp & 0x3F)
        return bytes([b1, b2, b3])
    elif cp \<= 0x10FFFF:
        b1 = 0xF0 | (cp >> 18)
        b2 = 0x80 | ((cp >> 12) & 0x3F)
        b3 = 0x80 | ((cp >> 6) & 0x3F)
        b4 = 0x80 | (cp & 0x3F)
        return bytes([b1, b2, b3, b4])
    else:
        raise ValueError(f"code point {cp:#x} সীমার বাইরে")


def encode_utf8(s: str) -> bytes:
    return b''.join(encode_utf8_one(ord(c)) for c in s)


# ── যাচাই — Python-এর নিজের encoder-এর সাথে তুলনা ──────
test_strings = ["Hello", "café", "বাংলা", "😀🎉", "ক্ষমা"]

for s in test_strings:
    mine = encode_utf8(s)
    builtin = s.encode('utf-8')
    status = "✓ মিলেছে" if mine == builtin else "✗ ভুল!"
    print(f"{s!r:15} নিজের: {mine.hex():25} বিল্ট-ইন: {builtin.hex():25} {status}")

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

'Hello'         নিজের: 48656c6c6f                বিল্ট-ইন: 48656c6c6f                ✓ মিলেছে
'café'          নিজের: 636166c3a9                বিল্ট-ইন: 636166c3a9                ✓ মিলেছে
'বাংলা'          নিজের: e0a6ace0a6bee0a682e0a6b2e0a6be  বিল্ট-ইন: e0a6ace0a6bee0a682e0a6b2e0a6be  ✓ মিলেছে
'😀🎉'          নিজের: f09f9880f09f8e89          বিল্ট-ইন: f09f9880f09f8e89          ✓ মিলেছে

এখন decoder লিখুন — এবং overlong encoding প্রত্যাখ্যান করুন:

def decode_utf8(data: bytes) -> list[int]:
    """UTF-8 byte থেকে code point list — overlong ও invalid byte প্রত্যাখ্যান সহ"""
    MIN_FOR_LEN = {1: 0x00, 2: 0x80, 3: 0x800, 4: 0x10000}
    codepoints = []
    i = 0
    while i \< len(data):
        b0 = data[i]
        if b0 \< 0x80:
            cp, n = b0, 1
        elif b0 & 0xE0 == 0xC0:
            cp, n = b0 & 0x1F, 2
        elif b0 & 0xF0 == 0xE0:
            cp, n = b0 & 0x0F, 3
        elif b0 & 0xF8 == 0xF0:
            cp, n = b0 & 0x07, 4
        else:
            raise ValueError(f"অবৈধ leading byte {b0:#x} — অবস্থান {i}")

        if i + n > len(data):
            raise ValueError("অসম্পূর্ণ sequence — stream শেষ হয়ে গেছে")

        for j in range(1, n):
            cb = data[i + j]
            if cb & 0xC0 != 0x80:
                raise ValueError(f"অবৈধ continuation byte {cb:#x} — অবস্থান {i+j}")
            cp = (cp \<\< 6) | (cb & 0x3F)

        if cp \< MIN_FOR_LEN[n]:
            raise ValueError(f"Overlong encoding সনাক্ত! code point {cp:#x} কে "
                              f"{n} byte-এ এনকোড করা হয়েছে, প্রয়োজন কম")
        if 0xD800 \<= cp \<= 0xDFFF:
            raise ValueError(f"Surrogate code point {cp:#x} UTF-8-এ অবৈধ")

        codepoints.append(cp)
        i += n
    return codepoints


# ── সঠিক encoding ──
print(decode_utf8(bytes.fromhex('e0a695')))     # [0x995] — 'ক'

# ── overlong আক্রমণ প্রত্যাখ্যান ──
try:
    decode_utf8(bytes.fromhex('c081'))          # overlong 'A'
except ValueError as e:
    print(f"প্রত্যাখ্যাত: {e}")

try:
    decode_utf8(bytes.fromhex('c0af'))          # famous overlong '/' — IIS exploit bytes
except ValueError as e:
    print(f"প্রত্যাখ্যাত: {e}")
[2453]
প্রত্যাখ্যাত: Overlong encoding সনাক্ত! code point 0x41 কে 2 byte-এ এনকোড করা হয়েছে, প্রয়োজন কম
প্রত্যাখ্যাত: Overlong encoding সনাক্ত! code point 0x2f কে 2 byte-এ এনকোড করা হয়েছে, প্রয়োজন কম

লক্ষ্য করুন 0x2f (/) exactly সেই byte value যা MS00-078 exploit-এ ব্যবহৃত হয়েছিল — আপনার decoder এখন এই ধরনের attack স্বয়ংক্রিয়ভাবে প্রত্যাখ্যান করে, ঠিক যেমন একটা compliant UTF-8 decoder করা উচিত।

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

UTF-8-এর leading-bit নিয়মগুলো শুধু কাগজে-কলমে তত্ত্ব নয় — bit shift আর mask দিয়ে হুবহু বাস্তবায়নযোগ্য, আর Python-এর নিজস্ব .encode('utf-8')-এর সাথে ঠিক মিলে যায়।

EXPERIMENT

UTF-16 surrogate pair বনাম code point — length mismatch সরাসরি দেখুন

Python 3 + Node.js (যদি থাকে)· ১০ মিনিট
# Python-এ string মানে code point sequence
s = "😀"
print(f"Python len(): {len(s)}")                    # 1
print(f"code points: {[hex(ord(c)) for c in s]}")   # ['0x1f600']

# UTF-16 code unit গণনা করতে চাইলে explicit encode লাগবে
utf16_units = len(s.encode('utf-16-le')) // 2
print(f"UTF-16 code unit সংখ্যা: {utf16_units}")     # 2
Python len(): 1
code points: ['0x1f600']
UTF-16 code unit সংখ্যা: 2

এখন JavaScript-এ (Node.js বা ব্রাউজার console-এ চালান):

const s = "😀";
console.log(s.length);                    // 2  — UTF-16 code unit!
console.log([...s].length);               // 1  — spread, code point iterate করে
console.log(s.codePointAt(0).toString(16)); // '1f600'
console.log(s[0]);                        // '\ud83d' — lone surrogate, garbage একা
console.log(s.slice(0, 1));               // ভাঙা অর্ধেক character
2
1
1f600
\ud83d
\ud83d

একই স্ট্রিং, তিনটা ভিন্ন সংখ্যা নির্ভর করে কী গণনা করছেন তার উপর — code point (Python len), UTF-16 code unit (JS .length), নাকি মানুষের চোখে “একটা emoji”। কোনোটাই ভুল নয় — প্রতিটা তার নিজের ইউনিটে সঠিক। ভুলটা হয় যখন প্রোগ্রামার ধরে নেন এই তিনটা সবসময় সমান।

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

একই টেক্সট ভিন্ন ভাষায় ভিন্ন 'length' রিপোর্ট করে — কারণ প্রতিটা ভাষা টেক্সটকে ভিন্ন ইউনিটে (code point, UTF-16 code unit) পরিমাপ করে, মানুষের 'অক্ষর' ধারণায় নয়।

নিজে বানান

BUILD IT

Mini UTF-8 Codec — সম্পূর্ণ, ভ্যালিডেটিং

Python · ●●●○○
  1. উপরের encode_utf8()/decode_utf8() ফাংশন দুটো একটা ক্লাসে একত্র করুন
  2. সব invalid case-এর জন্য নির্দিষ্ট, বর্ণনামূলক error দিন (overlong, lone surrogate, truncated, invalid continuation)
  3. ১০,০০০+ random code point দিয়ে round-trip test করুন — encode তারপর decode করলে মূল code point ফিরে আসে কি না
  4. একটা CLI টুল বানান যা যেকোনো UTF-8 ফাইলকে byte-বাই-byte বিশ্লেষণ করে দেখায়
class UTF8CodecError(ValueError):
    pass


class UTF8Codec:
    MIN_FOR_LEN = {1: 0x00, 2: 0x80, 3: 0x800, 4: 0x10000}
    MAX_CODEPOINT = 0x10FFFF

    @staticmethod
    def encode(codepoints) -> bytes:
        out = bytearray()
        for cp in codepoints:
            if not (0 \<= cp \<= UTF8Codec.MAX_CODEPOINT):
                raise UTF8CodecError(f"code point {cp:#x} সীমার বাইরে")
            if 0xD800 \<= cp \<= 0xDFFF:
                raise UTF8CodecError(f"surrogate code point {cp:#x} সরাসরি এনকোড অবৈধ")
            if cp \< 0x80:
                out.append(cp)
            elif cp \< 0x800:
                out += bytes([0xC0 | (cp >> 6), 0x80 | (cp & 0x3F)])
            elif cp \< 0x10000:
                out += bytes([0xE0 | (cp >> 12),
                              0x80 | ((cp >> 6) & 0x3F),
                              0x80 | (cp & 0x3F)])
            else:
                out += bytes([0xF0 | (cp >> 18),
                              0x80 | ((cp >> 12) & 0x3F),
                              0x80 | ((cp >> 6) & 0x3F),
                              0x80 | (cp & 0x3F)])
        return bytes(out)

    @staticmethod
    def decode(data: bytes):
        cps, i = [], 0
        while i \< len(data):
            b0 = data[i]
            if b0 \< 0x80:
                cp, n = b0, 1
            elif b0 & 0xE0 == 0xC0 and b0 not in (0xC0, 0xC1):
                cp, n = b0 & 0x1F, 2
            elif b0 & 0xF0 == 0xE0:
                cp, n = b0 & 0x0F, 3
            elif b0 & 0xF8 == 0xF0 and b0 \<= 0xF4:
                cp, n = b0 & 0x07, 4
            else:
                raise UTF8CodecError(f"অবৈধ leading byte {b0:#x} @ {i}")
            if i + n > len(data):
                raise UTF8CodecError(f"truncated sequence @ {i}")
            for j in range(1, n):
                cb = data[i + j]
                if cb & 0xC0 != 0x80:
                    raise UTF8CodecError(f"অবৈধ continuation byte {cb:#x} @ {i+j}")
                cp = (cp \<\< 6) | (cb & 0x3F)
            if cp \< UTF8Codec.MIN_FOR_LEN[n]:
                raise UTF8CodecError(f"overlong encoding @ {i}")
            if 0xD800 \<= cp \<= 0xDFFF:
                raise UTF8CodecError(f"surrogate code point decode হলো @ {i}")
            cps.append(cp)
            i += n
        return cps


# ── round-trip fuzz test ──────────────────────────────
import random

random.seed(1)
failures = 0
for _ in range(10_000):
    cp = random.randint(0, UTF8Codec.MAX_CODEPOINT)
    if 0xD800 \<= cp \<= 0xDFFF:
        continue  # surrogate — বৈধ code point না, স্কিপ
    encoded = UTF8Codec.encode([cp])
    decoded = UTF8Codec.decode(encoded)
    if decoded != [cp]:
        failures += 1
        print(f"ব্যর্থ: {cp:#x}{encoded.hex()}{decoded}")

print(f"{10_000 - failures}/10000 round-trip সফল")

প্রত্যাশিত: 10000/10000 round-trip সফল — কারণ encode আর decode একে অপরের যথাযথ inverse, precisely কারণ UTF-8 mapping-টা injective (যা এই লেসনে আমরা প্রমাণ করেছি)।

নিজে বাড়ান:

  1. একটা byte-বাই-byte “hex dump + annotation” প্রিন্টার লিখুন যা যেকোনো UTF-8 ফাইলের প্রতিটা byte-কে চিহ্নিত করে দেখায় — leading না continuation, কোন code point-এর অংশ
  2. WTF-8 সাপোর্ট যোগ করুন — lone surrogate encode/decode করার একটা আলাদা “lenient mode”
  3. UTF-16 encoder/decoder লিখুন surrogate pair logic সহ, তারপর আপনার UTF-8 codec-এর সাথে chain করে UTF-8 ↔ UTF-16 রূপান্তর বানান (মাঝখানে code point-এ গিয়ে)
  4. একটা “malformed UTF-8 repair” মোড যোগ করুন — invalid byte পেলে U+FFFD (replacement character) বসিয়ে এগিয়ে যান, error না ছুঁড়ে (এটাই বেশিরভাগ ব্রাউজার/OS আসলে করে)

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

Unicode/UTF-8 যেখানে সিদ্ধান্ত নেয়

MySQL-এর utf8 বনাম utf8mb4 MySQL-এর ঐতিহাসিক utf8 charset আসলে সম্পূর্ণ UTF-8 না — এটা সর্বোচ্চ ৩ byte পর্যন্ত সীমিত, তাই BMP-র বাইরের কোনো code point (বেশিরভাগ emoji সহ) সংরক্ষণ করতে পারে না। Emoji insert করতে গেলে truncation error বা silent data loss হতো। সমাধান হিসেবে MySQL 5.5.3-এ utf8mb4 charset যোগ করা হয় — প্রকৃত সম্পূর্ণ UTF-8 (৪ byte পর্যন্ত)। আজও নতুন ডেভেলপাররা এই ফাঁদে পড়েন যদি পুরনো টিউটোরিয়াল অনুসরণ করেন।

Java এবং JavaScript-এর char/String length। দুটোরই internal string representation UTF-16 code unit-ভিত্তিক — একটা ঐতিহাসিক সিদ্ধান্ত যখন Unicode-এর ধারণা ছিল “সব অক্ষর ১৬ বিটে আঁটবে” (BMP-ই যথেষ্ট মনে হয়েছিল ১৯৯০-এর দশকের শুরুতে)। Supplementary plane যোগ হওয়ার পর এই ধারণা ভুল প্রমাণিত হয়, কিন্তু API আর বদলানো সম্ভব হয়নি backward compatibility-র কারণে — codePointAt()/codePointCount() এখন আলাদাভাবে যোগ করতে হয়েছে সঠিক আচরণের জন্য।

Windows filesystem API এবং WTF-8। NTFS filename UTF-16-এ, কিন্তু validation ছাড়া — potentially ill-formed (lone surrogate সহ) হতে পারে। Rust-এর standard library তাই OsString (Windows-এ) এর জন্য WTF-8 ব্যবহার করে, যাতে এমন filename-ও losslessly represent করা যায় যেগুলো প্রকৃত বৈধ Unicode না।

IIS Directory Traversal (MS00-078, ২০০০)। আগে আলোচিত — overlong UTF-8 encoding দিয়ে path validation filter বাইপাস। আজও যেকোনো security-critical string validation লেখার সময় “canonical form-এ normalize করে তারপর যাচাই করুন” — একটা স্থায়ী নিয়ম হিসেবে রয়ে গেছে।

Twitter/SMS-এর character-count bug। সম্পূর্ণ emoji sequence (যেমন একটা flag emoji, যা আসলে দুইটা “regional indicator” code point-এর যোগফল, অথবা skin-tone modifier যুক্ত emoji, একাধিক code point-এর সমন্বয়) truncate হলে মাঝপথে ভেঙে একটা অর্থহীন বা ভাঙা glyph দেখায় — কারণ truncation logic code point বা UTF-16 unit গণনা করে, প্রকৃত “একটা emoji” বোঝে না।

Rust-এর String type-level গ্যারান্টি। Rust String টাইপ সবসময় বৈধ UTF-8 — এই invariant compiler/runtime দ্বারা রক্ষিত। Arbitrary byte প্রয়োজন হলে আলাদা টাইপ (Vec\<u8>) ব্যবহার করতে হয়, আর অবৈধ byte আসতে পারে এমন filesystem path-এর জন্য OsString — টাইপ সিস্টেমের মাধ্যমে “এই স্ট্রিং সবসময় বৈধ Unicode” নিশ্চয়তা কম্পাইল-টাইমে enforce করা।

PostgreSQL-এর কঠোর UTF-8 validation। PostgreSQL-এর UTF8 encoding overlong বা invalid byte sequence insert করার চেষ্টা করলে সরাসরি error দেয় — MySQL-এর পুরনো নীরব truncation আচরণের বিপরীতে, যা ডেটা করাপশন প্রতিরোধে বেশি নিরাপদ কিন্তু অপ্রত্যাশিত insert failure-এর কারণ হতে পারে যদি upstream ডেটা clean না হয়।

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

“Unicode নিজেই একটা encoding।”

না — এটা এই লেসনের কেন্দ্রীয় বিভ্রান্তি। Unicode একটা character set — একটা মানচিত্র যা প্রতিটা অক্ষরকে একটা code point (সংখ্যা) দেয়। UTF-8, UTF-16, UTF-32 হলো encoding — সেই সংখ্যাগুলোকে byte-এ রূপান্তরের নিয়ম।

“এই ফাইলটা Unicode-এ” বলাটা তাই অসম্পূর্ণ — এটা বলা উচিত “এই ফাইলটা UTF-8-এ” বা “UTF-16-এ”, কারণ একই Unicode code point সম্পূর্ণ ভিন্ন byte sequence দিতে পারে ভিন্ন encoding-এ (উপরের ‘A’-এর তিনটা রূপ মনে করুন — 41, 00 41, 00 00 00 41)।

“একটা code point মানেই একটা 'অক্ষর', সবসময় ১ byte বা ২ byte বা যাই হোক — একটা visual glyph।”

প্রথম অংশ (variable byte count) আমরা এই লেসনেই দেখেছি — ১ থেকে ৪ byte, code point অনুযায়ী পরিবর্তিত।

কিন্তু গভীরতর সমস্যা: একটা code point সবসময় একটা দৃশ্যমান “অক্ষর” নাও হতে পারে। কিছু code point স্বাধীনভাবে কিছুই আঁকে না — যেমন combining accent mark, বা ভাষা-নির্দিষ্ট modifier। বাংলার মতো script-এ এই সমস্যা আরো গভীর: একটা visually একক দেখতে অক্ষর (conjunct/যুক্তাক্ষর) আসলে একাধিক code point দিয়ে গঠিত হতে পারে।

পরের লেসনে আমরা এই সমস্যাটা পুরোপুরি খুলব — “code point” আর “মানুষের চোখে দেখা অক্ষর” যে এক জিনিস নয়, সেটাই বাংলা text processing-এর সবচেয়ে গুরুত্বপূর্ণ শিক্ষা।

“UTF-8-কে সবসময় BOM দিয়ে শুরু করা উচিত, যাতে টুল বুঝতে পারে এটা UTF-8।”

উল্টো — আধুনিক best practice হলো UTF-8 ফাইলে BOM এড়িয়ে চলা

কারণ: UTF-8-এর endianness সমস্যাই নেই (এটা byte-oriented, word-oriented না), তাই BOM-এর মূল উদ্দেশ্য (byte order নির্দেশ করা) এখানে অপ্রাসঙ্গিক। যা থেকে যায় সেটা শুধু একটা ঐচ্ছিক “সিগনেচার”, যা আমরা দেখেছি shebang, JSON parser, CSV header, string equality — সবকিছুতেই সমস্যা তৈরি করতে পারে।

আধুনিক convention: UTF-8 ধরে নিন ডিফল্ট হিসেবে (কোনো BOM ছাড়াই), আর explicit metadata (HTTP Content-Type: charset=utf-8, XML-এর \<?xml encoding="UTF-8"?>) দিয়ে যেখানে দরকার সেখানে ঘোষণা করুন।

“যেহেতু UTF-8 self-synchronizing, তাই যেকোনো byte sequence থেকে decode শুরু করলেই নিরাপদ।”

Self-synchronization মানে আপনি character boundary খুঁজে পেতে পারবেন কোনো random offset থেকে — কিন্তু এটা গ্যারান্টি দেয় না যে decode করা ফলাফল অর্থবহ

একটা truncated বা corrupted stream-এ self-sync দিয়ে আপনি পরের বৈধ boundary পর্যন্ত এগোতে পারবেন, কিন্তু সেই boundary পর্যন্ত যে byte-গুলো স্কিপ হলো, সেই তথ্যটা হারিয়ে গেছে। আর overlong/invalid byte এখনো explicitly reject করতে হয় — self-sync শুধু “কোথায় পরবর্তী character শুরু” প্রশ্নের উত্তর দেয়, “এই byte-গুলো বৈধ কি না” প্রশ্নের উত্তর দেয় না। দুটো আলাদা যাচাই, দুটোই প্রয়োজন একটা সঠিক decoder-এ।

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

1

হাতে-কলমে decode করুন: byte sequence E2 82 AC। প্রতিটা ধাপ দেখান — কয়টা byte, কী bit pattern, চূড়ান্ত code point কত, আর এটা কোন অক্ষর (ইঙ্গিত: একটা মুদ্রা চিহ্ন)।

যুক্তি

ধাপ ১ — leading byte বিশ্লেষণ:

E2 বাইনারিতে 1110 0010। উপরের bit pattern 1110xxxx মানে এটা একটা ৩-byte sequence-এর শুরু।

ধাপ ২ — তিনটা byte-ই বাইনারিতে লিখুন:

E2 = 1110 0010
82 = 1000 0010
AC = 1010 1100

ধাপ ৩ — প্রতিটা byte থেকে ডেটা বিট বের করুন:

byte1 (1110xxxx): নিচের ৪ বিট = 0010
byte2 (10xxxxxx): নিচের ৬ বিট = 000010
byte3 (10xxxxxx): নিচের ৬ বিট = 101100

(লক্ষ্য করুন byte2, byte3 দুটোই 10 দিয়ে শুরু — বৈধ continuation byte, নিশ্চিত করে এটা সঠিক ৩-byte sequence।)

ধাপ ৪ — বিটগুলো জোড়া লাগান (4+6+6 = ১৬ বিট):

0010 000010 101100
= 0010000010101100

ধাপ ৫ — hex-এ রূপান্তর:

0010 0000 1010 1100
  2    0    A    C
= 0x20AC

code point = U+20AC = € (EURO SIGN)।

>>> bytes.fromhex('e282ac').decode('utf-8')
'€'

Overlong চেক: ৩-byte encoding-এর সর্বনিম্ন সীমা U+08000x20AC > 0x0800, তাই এটা বৈধ, overlong নয়। ✓

2

একটা chat অ্যাপ ব্যবহারকারীর message ১০০টা “অক্ষরে” সীমাবদ্ধ করতে চায়, আর ভুলবশত message.length \> 100 (JavaScript) দিয়ে চেক করছে। কোন ধরনের input-এ এই কোডটা ভুল আচরণ করবে, আর কেন?

প্রয়োগ

ভুল আচরণ হবে supplementary-plane অক্ষরে — মূলত বেশিরভাগ emoji, কিছু বিরল CJK ideograph।

কারণ: JavaScript-এর .length UTF-16 code unit গণনা করে, code point বা মানুষের-দেখা “অক্ষর” নয়। একটা BMP-র বাইরের code point (যেমন 😀, U+1F600) surrogate pair হিসেবে ২টা code unit নেয়।

উদাহরণ: ব্যবহারকারী যদি ৫০টা emoji পাঠান, প্রতিটা মানুষের চোখে “১টা অক্ষর”:

const msg = "😀".repeat(50);
console.log(msg.length);           // 100 — কিন্তু আসলে ৫০টা "অক্ষর"!
console.log(msg.length > 100);     // false — সীমার মধ্যে বলে গণ্য হবে
console.log([...msg].length);      // 50 — প্রকৃত code point সংখ্যা

এখানে সিস্টেম ৫০টা emoji-কে “১০০-অক্ষর সীমার মধ্যে” মনে করবে, যদিও একজন সাধারণ ব্যবহারকারীর কাছে এটা মাত্র ৫০টা জিনিস টাইপ করা মনে হবে — সীমাটা কার্যকরভাবে অর্ধেক হয়ে গেল emoji-heavy মেসেজের জন্য।

উল্টো দিকেও ঘটতে পারে: যদি কেউ বাংলা টেক্সট পাঠান (যা পুরোপুরি BMP-এর ভেতরে, কোনো surrogate pair দরকার নেই), .length ঠিক code point সংখ্যা দেবে — কিন্তু (পরের লেসনে দেখব) সেটাও মানুষের-দেখা “অক্ষর” সংখ্যার সমান নাও হতে পারে, কারণ conjunct একাধিক code point দিয়ে গঠিত।

সঠিক সমাধান: [...message].length (spread operator, code point দিয়ে iterate করে) ন্যূনতম উন্নতি, কিন্তু সত্যিকারের সঠিক “অক্ষর” গণনার জন্য দরকার grapheme cluster-ভিত্তিক গণনা (Intl.Segmenter JavaScript-এ) — এটাও পরের লেসনের বিষয়।

3

প্রমাণ করুন UTF-8-এর code-point-থেকে-byte-sequence mapping-টা injective — অর্থাৎ দুইটা ভিন্ন code point কখনো একই byte sequence দিতে পারে না। কোন নিয়মটা এই injectivity নিশ্চিত করে?

যুক্তি

দাবি: overlong encoding নিষিদ্ধ থাকা অবস্থায়, UTF-8 encoding function E : \text{code point} \to \text{byte sequence} injective।

প্রমাণ কাঠামো — দুই ধাপে:

ধাপ ১ — একই byte-length-এর মধ্যে injective।

প্রতিটা byte-length ক্লাসে (১, ২, ৩, বা ৪ byte), encoding সরাসরি code point-এর বিটগুলোকে নির্দিষ্ট position-এ বসায় — কোনো তথ্য হারায় না, কোনো bit পুনর্ব্যবহার হয় না। এটা মূলত একটা bijection সেই ক্লাসের ভেতরে code point range আর সম্ভাব্য byte pattern-এর মধ্যে (leading bit-গুলো বাদে, যা fixed marker হিসেবে কাজ করে, data বহন করে না)। তাই একই length-এর দুইটা ভিন্ন code point কখনো একই byte pattern দিতে পারে না — সরাসরি বিটের একের-অধিক ম্যাপিং থেকে স্পষ্ট।

ধাপ ২ — বিভিন্ন byte-length-এর মধ্যে injective, ওভারল্যাপ এড়িয়ে।

এখানেই overlong-নিষেধাজ্ঞা কাজ করে। প্রতিটা length ক্লাসের জন্য একটা নির্দিষ্ট, non-overlapping code point range সংজ্ঞায়িত:

Lengthcode point range
১ byteU+0000 – U+007F
২ byteU+0080 – U+07FF
৩ byteU+0800 – U+FFFF
৪ byteU+10000 – U+10FFFF

এই রেঞ্জগুলো disjoint (একে অপরকে ছেদ করে না) — প্রতিটা code point ঠিক একটা রেঞ্জে পড়ে, তাই ঠিক একটা length ব্যবহার করতে বাধ্য। Overlong encoding (যেমন U+0041-কে ২ byte-এ জোর করে বসানো) আসলে এই নিয়ম ভাঙার একটা প্রচেষ্টা — একটা code point-কে তার “সঠিক” রেঞ্জের বাইরের একটা length-এ প্রকাশ করা। নিষেধাজ্ঞাটা ঠিক এটাই আটকায়, ফলে প্রতিটা code point-এর ঠিক একটাই বৈধ byte representation থাকে।

উপসংহার: ধাপ ১ (প্রতিটা ক্লাসের ভেতরে injective) + ধাপ ২ (ক্লাসগুলো disjoint, তাই ক্রস-ক্লাস collision অসম্ভব) = সম্পূর্ণ mapping injective। ∎

যদি overlong অনুমোদিত হতো, ধাপ ২ ভেঙে যেত — একই code point (U+0041) দুইটা ভিন্ন length-এ (১ byte এবং ২ byte) representable হতো, mapping-টা আর well-defined injective থাকত না (এখন “reverse” দিকে, byte sequence থেকে code point-এ, একাধিক বৈধ byte sequence একই code point দিত — যা functions লেসনের ভাষায় “codomain-এর একটা উপাদানের একাধিক valid preimage”, ঠিক injectivity ভাঙার সংজ্ঞা)। এই কারণেই RFC 3629 এটা কঠোরভাবে নিষিদ্ধ করে — শুধু পরিচ্ছন্নতার জন্য না, mapping-এর mathematical well-definedness রক্ষার জন্য।

4

আপনি একটা ডেটাবেস ডিজাইন করছেন যেটা বাংলা + ইংরেজি মিশ্র টেক্সট আর emoji সংরক্ষণ করবে, আর বেশিরভাগ query key/column নাম ইংরেজি। কোন এনকোডিং বাছবেন internal storage-এর জন্য, আর কেন?

ডিজাইন

সিদ্ধান্ত: UTF-8 (সম্পূর্ণ ৪-byte সমর্থন সহ, MySQL-এর ভাষায় utf8mb4)।

কারণ:

১. Space efficiency মিশ্র workload-এ ভালো। Schema, column নাম, query syntax, JSON structure — এসব সবই ASCII-ভিত্তিক, UTF-8-এ ১ byte/অক্ষর। শুধু user-generated বাংলা content ৩ byte/অক্ষর নেয় — কিন্তু সামগ্রিক storage-এ (indexes, metadata, protocol overhead সহ) UTF-8 প্রায় সবসময় জেতে যখন কোনো non-trivial অংশ ASCII-ভিত্তিক থাকে।

২. Tooling ও ecosystem সমর্থন সবচেয়ে ব্যাপক। প্রায় প্রতিটা modern database, programming language, network protocol UTF-8-কে ডিফল্ট ধরে নেয়। UTF-16 বেছে নিলে প্রতিটা ইন্টিগ্রেশন পয়েন্টে conversion overhead।

৩. Self-synchronization ডিবাগিং সহজ করে। Corrupted ডেটা বা ভুল truncation থেকে recovery, byte-level tool দিয়ে inspection — সবই UTF-8-এ সহজ কারণ character boundary যেকোনো offset থেকে খুঁজে পাওয়া যায়।

সতর্কতা — অবশ্যই utf8mb4 (MySQL) বা সমতুল্য পূর্ণ UTF-8 নিন, utf8 (পুরনো MySQL) নয় — নাহলে emoji insert হলে truncation বা silent corruption হবে, এই লেসনেই সেই real-world bug দেখেছি।

যা যথেষ্ট নয় — শুধু encoding নির্বাচন করলেই সব সমাধান হয় না:

  • VARCHAR(n) length-এর সংজ্ঞা সাবধানে যাচাই করুন — কিছু ডেটাবেসে n মানে byte, কিছুতে code point, প্রায় কোনোটাতেই grapheme cluster না। বাংলা টেক্সটে VARCHAR(100) আসলে code point-এ ১০০ মানে অনেক কম প্রকৃত “অক্ষর” (পরের লেসনে বিস্তারিত)।
  • Collation (তুলনা/sorting নিয়ম) আলাদা সিদ্ধান্ত — encoding ঠিক করে ডেটা কীভাবে সংরক্ষিত হবে, collation ঠিক করে কীভাবে তুলনা/সাজানো হবে। বাংলা-সচেতন collation না থাকলে sorting ভুল ক্রমে হতে পারে।
  • Application layer-এও UTF-8 নিশ্চিত করতে হবে — HTTP header, form encoding, API response — শেষ প্রান্ত পর্যন্ত সামঞ্জস্যপূর্ণ না হলে ডেটাবেস UTF-8 হলেও mojibake ঘটতে পারে ইনপুট/আউটপুট পথে।

মূল শিক্ষা: encoding নির্বাচন একটা প্রয়োজনীয় কিন্তু যথেষ্ট নয় এমন সিদ্ধান্ত — পুরো pipeline-এ (input, storage, comparison, output) সামঞ্জস্য বজায় রাখতে হয়।

5

একটা network client প্রতিটা byte আলাদা TCP packet-এ পাঠাচ্ছে (অস্বাভাবিক, কিন্তু ধরে নিন)। একটা বাংলা অক্ষর ‘ক’ (E0 A6 95) পাঠানোর সময় দ্বিতীয় packet-টা হারিয়ে যায় (network loss)। Receiver কী byte পায়, আর একটা ভালো UTF-8 decoder এই পরিস্থিতি কীভাবে সামলাবে?

প্রয়োগ

Receiver পায়: E0 এবং 95 — মাঝের A6 হারিয়ে গেছে।

Decoder-এর প্রতিক্রিয়া, ধাপে ধাপে:

১. E0 পড়ে বুঝবে “এটা একটা ৩-byte sequence-এর শুরু” (1110xxxx pattern), তাই পরের ২টা byte continuation হিসেবে প্রত্যাশা করবে।

২. পরের byte 95 (1001 0101) পড়বে — উপরের ২ বিট 10, তাই এটা structurally বৈধ continuation byte (মানে decoder বুঝতে পারবে না যে এটা “ভুল জায়গার” continuation — শুধু bit pattern দেখে সেটা বলা সম্ভব না)।

৩. তৃতীয় প্রত্যাশিত continuation byte-এর জায়গায় হয় stream শেষ হয়ে যাবে (যদি এটাই শেষ ডেটা), অথবা পরের অক্ষরের leading byte চলে আসবে সেখানে (যদি আরো ডেটা থাকে) — যেটা 10xxxxxx প্যাটার্নে নাও থাকতে পারে।

৪. একটা robust decoder তৃতীয় ধাপে continuation byte প্রত্যাশা করেছিল কিন্তু পায়নি এই ভুলটা ধরে ফেলবে (কারণ পরের byte 10xxxxxx pattern মানছে না, অথবা stream প্রত্যাশার আগেই শেষ হয়ে গেছে) — এবং একটা decode error ছুঁড়বে।

৫. Self-synchronization এখানে কাজে লাগে: error-এর পর decoder সহজেই পরের বৈধ character boundary খুঁজে নিতে পারবে — শুধু 10xxxxxx pattern-এর বাইরের পরের byte পর্যন্ত এগিয়ে যাবে, আর সেখান থেকে decoding আবার শুরু করবে। অর্থাৎ একটামাত্র corrupted অক্ষর নষ্ট হবে, পুরো বাকি স্ট্রিম নয়।

তুলনা করুন: যদি এটা একটা non-self-synchronizing encoding হতো (যেমন কিছু পুরনো stateful East Asian encoding, যেখানে একটা byte-এর অর্থ নির্ভর করে আগের সব byte-এর উপর — shift-state ধরনের ব্যবস্থা), একটা হারানো byte পুরো বাকি stream-কে ভুলভাবে পার্স করাতে পারত, কারণ synchronization পয়েন্ট পুনরুদ্ধার করার কোনো নির্ভরযোগ্য উপায় নেই।

ব্যবহারিক পরিণতি: এই কারণেই text-based network protocol (HTTP, streaming JSON) মোটামুটি resilient — একটা corrupted অংশ সাধারণত শুধু স্থানীয়ভাবে সমস্যা তৈরি করে, catastrophically পুরো বাকি ডেটা নষ্ট করে না। তবে বাস্তবে TCP নিজেই byte-stream integrity নিশ্চিত করে (checksum, retransmission), তাই এই ধরনের mid-character byte loss বাস্তবে খুবই বিরল — এই প্রশ্নটা মূলত UTF-8-এর নকশাগত resilience বোঝার জন্য একটা কৃত্রিম পরিস্থিতি।

এরপর কী

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

ইংরেজিতে প্রায় সবসময় হ্যাঁ — একটা code point মানে একটা glyph। কিন্তু বাংলার (আর দেবনাগরী, তামিল, আরবির মতো আরো বহু script-এর) জন্য এই ধারণাটা ভেঙে পড়ে। বাংলা একটা abugida — প্রতিটা consonant একটা inherent স্বর বহন করে, আর সেই স্বর বদলাতে বা একাধিক consonant জোড়া লাগাতে (যুক্তাক্ষর) একাধিক code point মিলে কাজ করে, কিন্তু চোখে দেখায় একটা একক, অখণ্ড glyph হিসেবে।

পরের লেসনে আমরা এই সমস্যাটা পুরোপুরি খুলব: Bengali Unicode block-এর গঠন, conjunct আর matra কীভাবে কাজ করে, len() কেন বাংলায় প্রায় সবসময় ভুল সংখ্যা দেয়, আর কীভাবে সঠিকভাবে “একটা অক্ষর” গণনা করতে হয় — grapheme cluster দিয়ে।

আরও পড়ুন