<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mahmoud Ibrahim — Confileo</title>
    <description>The latest articles on DEV Community by Mahmoud Ibrahim — Confileo (@support_confileo_ce7442eb).</description>
    <link>https://dev.to/support_confileo_ce7442eb</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4014249%2F29f6f97b-997a-4647-b320-47d0f92fee05.png</url>
      <title>DEV Community: Mahmoud Ibrahim — Confileo</title>
      <link>https://dev.to/support_confileo_ce7442eb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/support_confileo_ce7442eb"/>
    <language>en</language>
    <item>
      <title>"Invisible text is not empty: the five Unicode blanks apps actually keep"</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Wed, 02 Sep 2026 07:09:03 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/invisible-text-is-not-empty-the-five-unicode-blanks-apps-actually-keep-573c</link>
      <guid>https://dev.to/support_confileo_ce7442eb/invisible-text-is-not-empty-the-five-unicode-blanks-apps-actually-keep-573c</guid>
      <description>&lt;p&gt;Every few weeks someone asks the same question in a different costume: &lt;em&gt;how do I send an empty message on WhatsApp&lt;/em&gt;, &lt;em&gt;how do I make my Free Fire name invisible&lt;/em&gt;, &lt;em&gt;why does my Instagram bio lose its blank lines&lt;/em&gt;. The answer is the same each time, and it is not a trick inside any of those apps. It is Unicode.&lt;/p&gt;

&lt;h2&gt;
  
  
  A space is not a character. To an app, it is a separator.
&lt;/h2&gt;

&lt;p&gt;When you type a name, a message or a bio, the app trims it before saving. Leading and trailing whitespace goes, runs of spaces collapse, and a field that contains &lt;em&gt;only&lt;/em&gt; spaces becomes an empty string. Then the validation runs: empty name, empty message, rejected.&lt;/p&gt;

&lt;p&gt;So "invisible text" cannot be made of spaces. It has to be made of characters that &lt;strong&gt;render as nothing but are not whitespace to the trimming code&lt;/strong&gt;. Unicode has several, and they were never designed for this. Each exists for a real typographic or scripting reason, which is exactly why every app treats them differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five that matter, and what each was for
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;Code point&lt;/th&gt;
&lt;th&gt;Why it exists&lt;/th&gt;
&lt;th&gt;Who keeps it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hangul Filler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;U+3164&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A placeholder in Korean syllable composition — a "letter" that draws nothing&lt;/td&gt;
&lt;td&gt;Game nickname fields (Free Fire, PUBG Mobile), Instagram name and bio, TikTok nickname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Braille Pattern Blank&lt;/td&gt;
&lt;td&gt;&lt;code&gt;U+2800&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The braille cell with no dots raised — a printable glyph that is empty&lt;/td&gt;
&lt;td&gt;WhatsApp, Telegram and Discord messages, Instagram comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero-Width Space&lt;/td&gt;
&lt;td&gt;&lt;code&gt;U+200B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A line-break opportunity with no width, for scripts without spaces&lt;/td&gt;
&lt;td&gt;X posts, Apple Notes, HTML; stripped by most chat apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invisible Separator&lt;/td&gt;
&lt;td&gt;&lt;code&gt;U+2063&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marks an invisible boundary between items (think matrix indices)&lt;/td&gt;
&lt;td&gt;Facebook and Messenger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hair Space&lt;/td&gt;
&lt;td&gt;&lt;code&gt;U+200A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The thinnest typographic space&lt;/td&gt;
&lt;td&gt;Word, Google Docs, email&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is not random. &lt;strong&gt;Hangul Filler is a letter&lt;/strong&gt; (category &lt;code&gt;Lo&lt;/code&gt;), so a name validator that asks "does this contain at least one letter?" says yes. &lt;strong&gt;Braille Blank is a symbol&lt;/strong&gt; (&lt;code&gt;So&lt;/code&gt;) — printable, so a "message must not be empty" check passes. &lt;strong&gt;Zero-Width Space is a format character&lt;/strong&gt; (&lt;code&gt;Cf&lt;/code&gt;), and format characters are the first thing a sanitiser strips, which is why it fails in WhatsApp and works in a text editor.&lt;/p&gt;

&lt;p&gt;If you want to see the categories yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ㅤ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;⠀&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;​&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;⁣&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`U+&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{L}/u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;letter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{S}/u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;symbol&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Cf}/u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;format&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{Zs}/u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;space&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// U+3164 letter · U+2800 symbol · U+200B format · U+2063 format · U+200A space&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why an invisible name breaks after an update
&lt;/h2&gt;

&lt;p&gt;A game that once accepted &lt;code&gt;U+3164&lt;/code&gt; and now rejects it did not "patch invisible names". Somebody added a Unicode normalisation or a category filter to the name validator — usually to stop impersonation, because two names that look identical but differ by an invisible character are a moderation nightmare. The moment a validator runs &lt;code&gt;NFKC&lt;/code&gt; normalisation, &lt;code&gt;U+3164&lt;/code&gt; becomes &lt;code&gt;U+1160&lt;/code&gt; (Hangul Jungseong Filler) or is dropped entirely, and &lt;code&gt;U+FFA0&lt;/code&gt; (the halfwidth form) folds into the same thing.&lt;/p&gt;

&lt;p&gt;That is why any honest invisible-text tool offers &lt;strong&gt;several&lt;/strong&gt; characters instead of one, and says which app each survives. It is also why "test on your own account before spending a rename card" is the only advice that stays true across updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting it is trivial, which is the point
&lt;/h2&gt;

&lt;p&gt;Invisible does not mean hidden. Paste the text into anything that counts characters and the count gives it away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INVISIBLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0x200B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x200C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x200D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x2060&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xFEFF&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x2800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x3164&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0xFFA0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x2063&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x200A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x00A0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;invisible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;INVISIBLE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="nx"&gt;invisible&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invisible&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;abㅤ⠀&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { total: 4, invisible: 2 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that bite: iterate with &lt;code&gt;for…of&lt;/code&gt;, not &lt;code&gt;.length&lt;/code&gt; — &lt;code&gt;.length&lt;/code&gt; counts UTF-16 code units, so an emoji is 2 and a family emoji is 11, and your "invisible" count drifts. And normalise &lt;strong&gt;after&lt;/strong&gt; counting if you must normalise at all; &lt;code&gt;s.normalize('NFKC')&lt;/code&gt; removes the very characters you are trying to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other kind: text that carries a message nobody can see
&lt;/h2&gt;

&lt;p&gt;There is a second, unrelated trick that gets called "invisible text" too. Zero-width characters come in pairs that a program can tell apart — &lt;code&gt;U+200B&lt;/code&gt; and &lt;code&gt;U+200C&lt;/code&gt; — so a string of them can carry bits. Encode each byte of a message as eight zero-width characters, wrap the run in a visible carrier so the chat app accepts it, and the recipient pastes the "empty" message back into the same page to read it. It is steganography, not encryption: anyone with the decoder reads it. The &lt;a href="https://confileo.com/tools/ghost-text/" rel="noopener noreferrer"&gt;ghost text&lt;/a&gt; page does exactly that, and the newer &lt;a href="https://confileo.com/tools/invisible-text/" rel="noopener noreferrer"&gt;invisible text&lt;/a&gt; page is the plain-blank version — one tap copies the character an app keeps, and a checker box tells you whether what you pasted really contains one.&lt;/p&gt;

&lt;p&gt;Both run in the browser. There is nothing to upload; the "tool" is a clipboard write of a string the page already holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Blank &lt;strong&gt;message&lt;/strong&gt; in WhatsApp, Telegram, Discord: &lt;code&gt;U+2800&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Blank &lt;strong&gt;name&lt;/strong&gt; or bio line in Instagram, TikTok, Free Fire, PUBG: &lt;code&gt;U+3164&lt;/code&gt; (fallback &lt;code&gt;U+FFA0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Blank &lt;strong&gt;post&lt;/strong&gt; on X or a hidden line in Notes: &lt;code&gt;U+200B&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Facebook / Messenger: &lt;code&gt;U+2063&lt;/code&gt;. Documents and email: &lt;code&gt;U+200A&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Nothing works in a field that validates against &lt;code&gt;[a-z0-9._]&lt;/code&gt; — Instagram and Discord usernames — and no tool can change that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you maintain a name field and want to stop this: normalise with NFKC, then reject anything that is empty after removing &lt;code&gt;\p{Cf}&lt;/code&gt;, &lt;code&gt;\p{Zs}&lt;/code&gt; and the two Hangul fillers. Say so in the error message, because "invalid name" sends people straight back to the tools above.&lt;/p&gt;

</description>
      <category>unicode</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Unicode's bold alphabet has holes in it, and every text generator falls in</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Fri, 28 Aug 2026 04:13:52 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/unicodes-bold-alphabet-has-holes-in-it-and-every-text-generator-falls-in-1d8j</link>
      <guid>https://dev.to/support_confileo_ce7442eb/unicodes-bold-alphabet-has-holes-in-it-and-every-text-generator-falls-in-1d8j</guid>
      <description>&lt;p&gt;If you have ever built a "fancy text" generator — the things people paste into&lt;br&gt;
Instagram bios and Discord names — you have written this function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Looks right. Is wrong.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toBold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1D400&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// A-Z&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mh"&gt;0x1D41A&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// a-z&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Map &lt;code&gt;A&lt;/code&gt; onto &lt;code&gt;U+1D400&lt;/code&gt;, add the offset, done. It works. You test it with&lt;br&gt;
&lt;code&gt;"Hello World"&lt;/code&gt;, get &lt;strong&gt;𝐇𝐞𝐥𝐥𝐨 𝐖𝐨𝐫𝐥𝐝&lt;/strong&gt;, and ship it.&lt;/p&gt;

&lt;p&gt;Then someone types their name in cursive and gets &lt;code&gt;☐&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The block is not contiguous
&lt;/h2&gt;

&lt;p&gt;The Mathematical Alphanumeric Symbols block, &lt;code&gt;U+1D400&lt;/code&gt;–&lt;code&gt;U+1D7FF&lt;/code&gt;, holds about a&lt;br&gt;
dozen styled Latin alphabets: bold, italic, bold italic, script, bold script,&lt;br&gt;
fraktur, double-struck, sans-serif, monospace, and the digit sets.&lt;/p&gt;

&lt;p&gt;It reads like a clean set of 26-letter runs. It is not. When the block was&lt;br&gt;
encoded, some of those letters &lt;strong&gt;already existed&lt;/strong&gt; in the Letterlike Symbols&lt;br&gt;
block, &lt;code&gt;U+2100&lt;/code&gt;–&lt;code&gt;U+214F&lt;/code&gt;, because mathematicians had been using them as named&lt;br&gt;
constants for decades — ℋ for the Hamiltonian, ℒ for the Lagrangian,&lt;br&gt;
ℝ for the reals. Unicode does not encode the same character twice. So rather&lt;br&gt;
than duplicate them, it left those slots in the new block &lt;strong&gt;permanently&lt;br&gt;
unassigned&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Five alphabets have gaps. Here is the complete set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Style&lt;/th&gt;
&lt;th&gt;Missing letters&lt;/th&gt;
&lt;th&gt;Where Unicode actually put them&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Script&lt;/td&gt;
&lt;td&gt;B E F H I L M R, e g o&lt;/td&gt;
&lt;td&gt;ℬ ℰ ℱ ℋ ℐ ℒ ℳ ℛ, ℯ ℊ ℴ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fraktur&lt;/td&gt;
&lt;td&gt;C H I R Z&lt;/td&gt;
&lt;td&gt;ℭ ℌ ℑ ℜ ℨ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Double-struck&lt;/td&gt;
&lt;td&gt;C H N P Q R Z&lt;/td&gt;
&lt;td&gt;ℂ ℍ ℕ ℙ ℚ ℝ ℤ&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Italic (serif)&lt;/td&gt;
&lt;td&gt;h&lt;/td&gt;
&lt;td&gt;ℎ (Planck's constant)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those is a hole your arithmetic falls into. &lt;code&gt;𝒜 + 1&lt;/code&gt; is not &lt;code&gt;ℬ&lt;/code&gt;, it&lt;br&gt;
is &lt;code&gt;U+1D49D&lt;/code&gt;, which is nothing at all. Your users see tofu, and only for certain&lt;br&gt;
letters, which is why this survives testing: &lt;code&gt;"Hello"&lt;/code&gt; breaks in script and&lt;br&gt;
fraktur, &lt;code&gt;"World"&lt;/code&gt; does not.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix is a lookup, not cleverness
&lt;/h2&gt;

&lt;p&gt;There is no formula. The gaps are historical accident, so the only correct&lt;br&gt;
implementation is an explicit exception table consulted before the arithmetic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;digit&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;holes&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HOLES_SCRIPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;B&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℬ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;E&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℰ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;F&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℱ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;H&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℋ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;I&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℐ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;L&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℒ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;M&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℳ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;R&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℛ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℯ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℊ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;o&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ℴ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;styleChar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;holes&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;holes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;       &lt;span class="c1"&gt;// check the table FIRST&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upper&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upper&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;122&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;97&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;57&lt;/span&gt;  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;digit&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCodePoint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;digit&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                    &lt;span class="c1"&gt;// pass everything else through&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test that catches it is a pangram, checked for the reserved codepoints&lt;br&gt;
rather than eyeballed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RESERVED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="mh"&gt;0x1D455&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D49D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4A0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4A1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4A3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4A4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4A7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4A8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mh"&gt;0x1D4AD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4BA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4BC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D4C4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D506&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D50B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D50C&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D515&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mh"&gt;0x1D51D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D53A&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D53F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D545&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D547&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D548&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D549&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x1D551&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;How vexingly quick daft zebras jump&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;RESERVED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;codePointAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eyeballing does not work, because &lt;code&gt;☐&lt;/code&gt; is what a &lt;em&gt;missing font&lt;/em&gt; looks like too.&lt;br&gt;
Checking the codepoints tells you which of the two you have.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three more traps in the same neighbourhood
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Iterate codepoints, not code units.&lt;/strong&gt; Every character you are producing is&lt;br&gt;
astral-plane — above &lt;code&gt;U+FFFF&lt;/code&gt;, so two UTF-16 code units. The moment your input&lt;br&gt;
contains an emoji, or someone pastes your own output back in, &lt;code&gt;for (let i = 0;&lt;br&gt;
i &amp;lt; text.length; i++)&lt;/code&gt; splits a surrogate pair and you emit garbage. &lt;code&gt;for…of&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;[...text]&lt;/code&gt; both iterate by codepoint. Use them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Seed your zalgo.&lt;/strong&gt; Glitch text works by stacking combining diacritics —&lt;br&gt;
&lt;code&gt;U+0300&lt;/code&gt;–&lt;code&gt;U+036F&lt;/code&gt; — on each letter, and there is no limit on how many can stack.&lt;br&gt;
The obvious implementation reaches for &lt;code&gt;Math.random()&lt;/code&gt;. Do not: if the output is&lt;br&gt;
rendered reactively, every keystroke re-randomises the &lt;em&gt;whole string&lt;/em&gt;, so the&lt;br&gt;
text visibly crawls while the user types and the thing they copy is not the&lt;br&gt;
thing they were looking at when they decided they liked it. Derive the seed from&lt;br&gt;
the input and run a tiny PRNG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;^=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4294967296&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Make upside-down text an involution.&lt;/strong&gt; Flipping is two operations: substitute&lt;br&gt;
each character for its turned twin (&lt;code&gt;a&lt;/code&gt; → &lt;code&gt;ɐ&lt;/code&gt;, &lt;code&gt;U+0250&lt;/code&gt;), then reverse the&lt;br&gt;
string, because a genuinely rotated line reads from what was its end. Skip the&lt;br&gt;
reversal and you get mirror writing, which is the most common bug in these&lt;br&gt;
tools. But there is a subtler one: half your users arrive with text that is&lt;br&gt;
&lt;em&gt;already&lt;/em&gt; upside down and want it turned back. If your table only maps &lt;code&gt;a → ɐ&lt;/code&gt;,&lt;br&gt;
pasting &lt;code&gt;ɐ&lt;/code&gt; back in leaves it untouched. Fill the reverse direction from the&lt;br&gt;
forward table and the same function does both jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;FLIP&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;FLIP&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;FLIP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// only fill gaps; hand-written pairs win&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The part nobody tells users
&lt;/h2&gt;

&lt;p&gt;Styled Unicode is worse for accessibility and for search, and it is worth saying&lt;br&gt;
so in the UI rather than leaving people to find out.&lt;/p&gt;

&lt;p&gt;A screen reader does not read &lt;strong&gt;𝗯𝗼𝗹𝗱&lt;/strong&gt; as "bold". It reads it as &lt;em&gt;"mathematical&lt;br&gt;
bold small b, mathematical bold small o, mathematical bold small l,&lt;br&gt;
mathematical bold small d"&lt;/em&gt; — because that is what those characters are named.&lt;br&gt;
Search engines do not match 𝘀𝗵𝗼𝗲𝘀 to a search for &lt;code&gt;shoes&lt;/code&gt; either.&lt;/p&gt;

&lt;p&gt;That makes these styles genuinely good for a decorative username or one accent&lt;br&gt;
word, and genuinely bad for a whole bio, a business name people need to find, or&lt;br&gt;
anything that has to be read aloud. Most generators do not mention it. It costs&lt;br&gt;
one sentence and it is the difference between a tool and a trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working reference
&lt;/h2&gt;

&lt;p&gt;Every finding here came out of building the text tools on&lt;br&gt;
&lt;a href="https://confileo.com/tools/fancy-text-generator/" rel="noopener noreferrer"&gt;Confileo&lt;/a&gt; — 26 styles, the&lt;br&gt;
full holes table, seeded glitch and a two-way flip, all client-side. The&lt;br&gt;
&lt;a href="https://confileo.com/tools/upside-down-text/" rel="noopener noreferrer"&gt;upside-down one&lt;/a&gt; is the quickest&lt;br&gt;
way to check the involution claim: paste its output back into its own box and&lt;br&gt;
you should get your original line, character for character.&lt;/p&gt;

&lt;p&gt;If you are building your own, the short version is: &lt;strong&gt;check the table before you&lt;br&gt;
do the arithmetic, iterate by codepoint, seed anything random, and tell your&lt;br&gt;
users what the output costs them.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>unicode</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Arabic PDF bug was never in my code — it was the library version</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Wed, 19 Aug 2026 00:31:04 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/the-arabic-pdf-bug-was-never-in-my-code-it-was-the-library-version-8a9</link>
      <guid>https://dev.to/support_confileo_ce7442eb/the-arabic-pdf-bug-was-never-in-my-code-it-was-the-library-version-8a9</guid>
      <description>&lt;p&gt;In my &lt;a href="https://dev.to/support_confileo_ce7442eb/why-arabic-text-comes-out-backwards-when-you-extract-it-from-a-pdf-and-how-to-fix-it-548g"&gt;last post&lt;/a&gt; I wrote about why Arabic comes out of PDF extraction in reverse word order. A commenter asked the obvious follow-up question, and it is the one I want to answer properly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Did you end up running a full bidi pass, or approximating with run-level heuristics? That mixed-direction case is usually where the heuristics fall apart.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Neither. And that turned out to be the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I thought the problem was
&lt;/h2&gt;

&lt;p&gt;The naive mental model goes like this: the PDF stores glyphs in the order they were painted, painting for RTL text runs right-to-left, so extraction hands you a line that reads last-word-first. Therefore you must reconstruct logical order yourself — which means implementing the Unicode Bidirectional Algorithm (UAX #9) over the positioned glyphs.&lt;/p&gt;

&lt;p&gt;That is a genuinely hard thing to do, for a reason the commenter named exactly: UAX #9 needs a base paragraph direction, and the PDF never stored one. You have to infer it from the script of the runs. Get it wrong on a line like &lt;code&gt;المبلغ: 128 SAR&lt;/code&gt; and your "fix" flips the Latin and the digits too. You have turned one bug into two, and the second one only shows up on invoices and CVs — which is to say, on almost every document a user actually cares about.&lt;/p&gt;

&lt;p&gt;I wrote a chunk of that logic. It half worked, which is worse than not working, because half-working code makes you write more of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the problem actually was
&lt;/h2&gt;

&lt;p&gt;Then I ran the same PDF through the same code on a different machine and got correct output.&lt;/p&gt;

&lt;p&gt;The extraction library was handing me visual order on one machine and logical order on the other. Same file, same code, different dependency version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LibreOffice.&lt;/strong&gt; Version 7.1 (2021), which is what a lot of stable server distributions still ship, reverses Arabic on PDF import — it gives you paint order. A modern LibreOffice extracts logical order directly. The server was on 7.1. My laptop was not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PyMuPDF.&lt;/strong&gt; Same class of bug, different library. Below 1.24, Arabic comes back visual and reversed. From 1.24 onward it comes back logical.&lt;/p&gt;

&lt;p&gt;So the correct amount of bidi reconstruction code is &lt;strong&gt;zero&lt;/strong&gt;. Both libraries already do it, in the versions where they do it. Everything I had written was scrambling text that had already arrived in the right order, on exactly the machines where the library was correct — which is why it looked like an intermittent bug and cost me months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap that hid it for weeks
&lt;/h2&gt;

&lt;p&gt;Here is the part worth stealing, because it is what made this so hard to see.&lt;/p&gt;

&lt;p&gt;The pipeline had two engines: LibreOffice as primary, and &lt;code&gt;pdf2docx&lt;/code&gt; (which sits on PyMuPDF) as a fallback. Sensible design. Then PyMuPDF 1.26.5 removed &lt;code&gt;Rect.get_area()&lt;/code&gt;, a method &lt;code&gt;pdf2docx&lt;/code&gt; 0.5.8 still calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pdf2docx 0.5.8, somewhere in layout analysis
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_area&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;page_area&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AttributeError&lt;/code&gt;. The fallback engine crashed on import-time-adjacent code paths, the orchestrator caught it, and everything silently fell through to the other engine.&lt;/p&gt;

&lt;p&gt;Nothing was logged as broken because nothing &lt;em&gt;was&lt;/em&gt; broken from the caller's point of view — output came back, tests passed, files converted. But I could no longer tell which engine had produced any given file, so I was debugging Arabic ordering against an engine that had not run in weeks.&lt;/p&gt;

&lt;p&gt;The fix was a two-line shim rather than pinning PyMuPDF backwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_ensure_pdf2docx_compat&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;pdf2docx 0.5.8 calls Rect.get_area(), removed in PyMuPDF 1.26.
    Restore it rather than pinning back to a version that returns visual order.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cls&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fitz&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IRect&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;get_area&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get_area&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The general lesson: &lt;strong&gt;a silent fallback is a debugging tarpit.&lt;/strong&gt; If your orchestrator can switch engines without telling you, the first thing to add is not a retry, it is a log line naming which engine produced the output.&lt;/p&gt;

&lt;h2&gt;
  
  
  One infrastructure footgun on the way
&lt;/h2&gt;

&lt;p&gt;Upgrading LibreOffice on a server usually means dropping a newer build somewhere and repointing the binary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sf&lt;/span&gt; /opt/libreoffice26.2/program/soffice /usr/bin/soffice
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ln -sf&lt;/code&gt; will happily create that symlink when the target does not exist yet. You get a dangling link, LibreOffice stops working entirely, and the error you get back has nothing to do with symlinks. Confirm the binary is there &lt;strong&gt;before&lt;/strong&gt; repointing, and restart the service afterwards — a long-running process that resolved &lt;code&gt;soffice&lt;/code&gt; at startup will keep using the old path until it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is left after you delete the reversal code
&lt;/h2&gt;

&lt;p&gt;Not nothing, but much less than you would expect. Two things still need doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normalise presentation forms.&lt;/strong&gt; Some PDFs encode Arabic using the Unicode presentation-form blocks (U+FB50–FDFF, U+FE70–FEFF) — the pre-shaped initial/medial/final glyph variants — instead of base letters. The text is technically correct and will look fine, but it is not searchable, will not match a query typed normally, and behaves badly in Word. NFKC folds them back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that this is the &lt;strong&gt;only&lt;/strong&gt; normalisation you want here. NFKC is safe for this because the presentation forms have canonical decompositions to their base letters. Do not go further and start stripping diacritics; you will damage Quranic text and vowelised teaching material.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mark direction in the output format.&lt;/strong&gt; For DOCX, logical order in the string is not enough — Word needs to be told the run is RTL, or it renders correctly-ordered text with left-aligned paragraph flow, which looks subtly wrong to a native reader:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;w:rPr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;w:rtl/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;w:rFonts&lt;/span&gt; &lt;span class="na"&gt;w:cs=&lt;/span&gt;&lt;span class="s"&gt;"Arabic Typesetting"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/w:rPr&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;w:pPr&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;w:bidi/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/w:pPr&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;w:bidi&lt;/code&gt; on the paragraph, &lt;code&gt;w:rtl&lt;/code&gt; on the run, and a complex-script font via &lt;code&gt;w:cs&lt;/code&gt; — miss the last one and Word substitutes something that breaks the cursive joins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you are debugging reversed RTL text in an extraction pipeline, check your library versions before you write a single line of bidi logic. The odds are high that the layer below you already solved it and you are about to un-solve it.&lt;/p&gt;

&lt;p&gt;And add a version matrix to your i18n tests. Not a version &lt;em&gt;floor&lt;/em&gt; — a matrix. This class of bug is invisible in a test suite that only ever runs one version of the thing doing the extracting, and it will reappear the day someone deploys to a distribution that pins an older package.&lt;/p&gt;

&lt;p&gt;I write these up as I hit them while building &lt;a href="https://confileo.com/" rel="noopener noreferrer"&gt;Confileo&lt;/a&gt;, a free PDF toolkit where Arabic support is the point rather than an afterthought. If you have a PDF that still comes out wrong, I would genuinely like to see it — the interesting bugs are always in the files, not in the spec.&lt;/p&gt;

</description>
      <category>pdf</category>
      <category>python</category>
      <category>i18n</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why Arabic text comes out backwards when you extract it from a PDF (and how to fix it)</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Sat, 04 Jul 2026 00:15:53 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/why-arabic-text-comes-out-backwards-when-you-extract-it-from-a-pdf-and-how-to-fix-it-548g</link>
      <guid>https://dev.to/support_confileo_ce7442eb/why-arabic-text-comes-out-backwards-when-you-extract-it-from-a-pdf-and-how-to-fix-it-548g</guid>
      <description>&lt;p&gt;If you've ever built a feature that extracts text from PDFs, an Arabic-speaking user has probably filed this bug: &lt;em&gt;"the words come out in reverse order."&lt;/em&gt; Not the letters — the &lt;strong&gt;words&lt;/strong&gt;. Every line reads last-word-first.&lt;/p&gt;

&lt;p&gt;I spent the better part of a year fixing this class of bugs while building &lt;a href="https://confileo.com" rel="noopener noreferrer"&gt;Confileo&lt;/a&gt;, a free PDF toolkit with first-class Arabic support. Here's what's actually going on, because almost every explanation online is wrong or incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four distinct failure modes
&lt;/h2&gt;

&lt;p&gt;People say "Arabic breaks" as if it's one bug. It's four:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Visual vs logical order (the reversed-words bug)
&lt;/h3&gt;

&lt;p&gt;A PDF doesn't store &lt;em&gt;text&lt;/em&gt; the way a Word file does — it stores &lt;strong&gt;positioned glyph runs&lt;/strong&gt;: "paint these shapes at these coordinates." For left-to-right scripts, the paint order happens to match the reading order, so naive extraction works by accident.&lt;/p&gt;

&lt;p&gt;Arabic is right-to-left. Many PDF generators emit the glyph runs in &lt;strong&gt;visual order&lt;/strong&gt; — the order they appear on screen, left to right. A naive extractor concatenates the runs as stored and produces every line word-reversed. The text was never "reversed" in the file; your extractor just assumed paint order == reading order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; reconstruct logical order using glyph positions + the Unicode Bidirectional Algorithm (UAX #9), not the content-stream order. Libraries like PyMuPDF already return text in logical order — a common mistake is "fixing" that output by reversing it again, which is how you get double-reversed text. Rule of thumb: &lt;strong&gt;never reverse Arabic yourself.&lt;/strong&gt; If it looks backwards, your rendering layer lacks bidi support; the data is usually fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Disconnected letters (the ransom-note bug)
&lt;/h3&gt;

&lt;p&gt;Arabic letters are contextual: ع renders differently in initial, medial, final and isolated positions, and letters join. That joining is applied at render time by a shaping engine (HarfBuzz being the standard).&lt;/p&gt;

&lt;p&gt;If any step of your pipeline round-trips text through a non-shaping renderer — a canvas library, a barebones PDF writer, an image caption filter — you get isolated forms: م ر ح ب ا instead of مرحبا. (Fun fact: ffmpeg burns subtitles correctly &lt;em&gt;only&lt;/em&gt; because libass links HarfBuzz + FriBidi. Strip those and Arabic subtitles shatter.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; only render Arabic through stacks that do real shaping. If you're generating PDFs headlessly, Chromium/Puppeteer shape correctly; many lightweight PDF writers do not.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Font substitution (the hollow-boxes bug)
&lt;/h3&gt;

&lt;p&gt;Your server converts a document referencing a font it doesn't have. The substitution fallback has no Arabic glyphs → tofu boxes. This is purely an ops problem: conversion servers get provisioned with Latin fonts only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; install a proper Arabic set (Noto Naskh Arabic, Amiri, Cairo) on every box that renders documents, and configure fontconfig so substitution prefers them.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Scanned PDFs (the empty-output bug)
&lt;/h3&gt;

&lt;p&gt;A scanned PDF has no text layer at all — each page is a bitmap. Extraction returns nothing, or worse, your pipeline silently emits an "editable" file full of images. The only real answer is OCR, and generic OCR models trained mostly on Latin do poorly on connected Arabic script; use one with genuine Arabic training (PaddleOCR and Tesseract's ara traineddata are workable starting points).&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers and mixed text: the boss level
&lt;/h2&gt;

&lt;p&gt;Dates like 2024/05/13 inside an RTL sentence follow their own bidi embedding rules — digits are LTR &lt;em&gt;within&lt;/em&gt; an RTL context. Get the algorithm wrong and "من 2020 إلى 2024" turns into "من 2024 إلى 2020", silently corrupting the &lt;em&gt;meaning&lt;/em&gt;. This is why you implement UAX #9 (or use a library that does) instead of hand-rolling direction logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / steal the approach
&lt;/h2&gt;

&lt;p&gt;I've put all of this into production converters you can poke at for free — &lt;a href="https://confileo.com/convert-arabic-word-to-pdf/" rel="noopener noreferrer"&gt;Arabic Word → PDF&lt;/a&gt; (shaping + bundled fonts) and &lt;a href="https://confileo.com/convert-arabic-pdf-to-word/" rel="noopener noreferrer"&gt;Arabic PDF → Word&lt;/a&gt; (logical-order extraction). No signup needed, so they're easy to use as a reference for what correct output should look like when testing your own pipeline.&lt;/p&gt;

&lt;p&gt;If you're dealing with the same class of bugs in Hebrew or Farsi — same four failure modes, same fixes. Questions welcome in the comments; I've probably hit whatever edge case you're stuck on. 🐪&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>i18n</category>
      <category>pdf</category>
      <category>unicode</category>
    </item>
  </channel>
</rss>
