<?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>I printed Arabic to PDF with 13 fonts. Only one produced text you can search</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Wed, 23 Sep 2026 22:16:00 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/i-printed-arabic-to-pdf-with-13-fonts-only-one-produced-text-you-can-search-4he0</link>
      <guid>https://dev.to/support_confileo_ce7442eb/i-printed-arabic-to-pdf-with-13-fonts-only-one-produced-text-you-can-search-4he0</guid>
      <description>&lt;p&gt;If you generate PDFs from HTML with Puppeteer, Playwright or headless Chrome, and any of your users write in Arabic, there is a good chance your PDFs contain no searchable Arabic text. They look perfect. You cannot tell by opening them. You find out when a user says "I can't copy the name out of the invoice", or when an applicant tracking system rejects a CV that reads fine to a human.&lt;/p&gt;

&lt;p&gt;I ran into this while building a text-to-PDF feature, and then measured it properly: 13 fonts, the same Arabic paragraph, printed by headless Chrome, read back with pdf.js. One font passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;The document is four short lines: a title (خطاب تعريف), a sentence that contains ordinary words such as الموظف, a line that mixes Arabic with Latin text and digits, and three words chosen because they contain the lam-alef ligature: الأمر، الآن، لإدارة.&lt;/p&gt;

&lt;p&gt;Each font is embedded with &lt;code&gt;@font-face&lt;/code&gt;, the page is printed with &lt;code&gt;page.pdf()&lt;/code&gt;, and the PDF goes straight into &lt;code&gt;pdfjs-dist&lt;/code&gt; to get the text layer. Three checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Presentation forms.&lt;/strong&gt; Are the stored characters real Arabic letters (U+0600–U+06FF), or the Unicode &lt;em&gt;presentation forms&lt;/em&gt; (U+FB50–FDFF, U+FE70–FEFF) that describe a letter's shape instead of the letter?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lam-alef order.&lt;/strong&gt; Do الأمر / الآن / لإدارة come back as typed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whole words.&lt;/strong&gt; How many of the 18 Arabic words can a plain text search find?&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;Chrome 135, pdf.js 4.8, September 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Font&lt;/th&gt;
&lt;th&gt;Stored as&lt;/th&gt;
&lt;th&gt;لا ligature&lt;/th&gt;
&lt;th&gt;Words found&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amiri Regular&lt;/td&gt;
&lt;td&gt;real letters&lt;/td&gt;
&lt;td&gt;in order&lt;/td&gt;
&lt;td&gt;15 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amiri Bold&lt;/td&gt;
&lt;td&gt;real letters&lt;/td&gt;
&lt;td&gt;in order&lt;/td&gt;
&lt;td&gt;16 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IBM Plex Sans Arabic&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Noto Sans Arabic&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arial&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tahoma&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Times New Roman&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Segoe UI&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traditional Arabic&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Simplified Arabic&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arabic Typesetting&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sakkal Majalla&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Andalus&lt;/td&gt;
&lt;td&gt;presentation forms&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 / 18&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An earlier run with web fonts told the same story. Readex Pro, Scheherazade New and Lateef stored real letters but reversed the lam-alef ligature; Noto Naskh Arabic, Noto Kufi Arabic, Cairo, Almarai and Tajawal stored presentation forms.&lt;/p&gt;

&lt;p&gt;The two or three words Amiri "misses" are not a font problem: pdf.js sometimes puts a space inside a word where one text run ends and the next begins. All the letters are there and in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Arabic is cursive. Most letters have up to four shapes depending on their neighbours, and some pairs fuse into a single glyph. Chrome shapes the text with HarfBuzz and writes the resulting glyphs into the PDF. For the text to be extractable, the PDF also needs a &lt;code&gt;ToUnicode&lt;/code&gt; map: glyph number → the characters it represents.&lt;/p&gt;

&lt;p&gt;For most Arabic fonts, Chrome cannot trace a shaped glyph back to the character that produced it, so the map points at the presentation-form codepoint for that shape. The file renders correctly, because rendering uses the glyphs. Extraction uses the map, so what you copy is a different string from what you typed. Search for الموظف and you are searching for letters that are not in the file.&lt;/p&gt;

&lt;p&gt;The ligature is the second trap. لا is one glyph standing for two letters, lam followed by alef. Several fonts get every other letter right and then map that glyph back as alef + lam, reversed. That breaks every word where ال is followed by أ, إ or آ, which in Arabic is a lot of words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own font
&lt;/h2&gt;

&lt;p&gt;I put the test in a small repo so it can run in CI next to your templates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/mahmoudQq2023/arabic-pdf-fonts
&lt;span class="nb"&gt;cd &lt;/span&gt;arabic-pdf-fonts &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install
&lt;/span&gt;node check-font.mjs ./fonts/YourFont-Regular.ttf ./fonts/YourFont-Bold.ttf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;YourFont-Regular.ttf   BROKEN: presentation forms (text not searchable)  (0/18 words searchable)
Amiri-Regular.ttf      OK: real letters, lam-alef in order  (15/18 words searchable)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It exits with code 1 if any font fails, and &lt;code&gt;--json&lt;/code&gt; gives you something to assert on. The results page is at &lt;a href="https://mahmoudqq2023.github.io/arabic-pdf-fonts/" rel="noopener noreferrer"&gt;mahmoudqq2023.github.io/arabic-pdf-fonts&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed in production
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amiri is embedded for every Arabic document&lt;/strong&gt;, including a real Bold file. Synthetic bold draws each glyph as its own run, which can split a bold heading into one run per letter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No font picker.&lt;/strong&gt; Offering Arabic fonts that look nicer but produce unsearchable files felt like a trap for users who will never know why their CV is not being parsed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No promise about harakat.&lt;/strong&gt; Diacritics (U+064B–0652) rendered correctly in every font and came back as U+0000 in all of them. Copy gives you the base letters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A round-trip test in the repo.&lt;/strong&gt; The page never shows this bug, so only a test that reads the PDF back will catch a regression when someone swaps a font later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The feature this came from is &lt;a href="https://confileo.com/tools/text-to-pdf/" rel="noopener noreferrer"&gt;Confileo's text to PDF converter&lt;/a&gt;. If you only need to turn Arabic text into a searchable PDF, you can use it directly; if you generate PDFs yourself, run the checker on your fonts before your users find the problem for you.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>pdf</category>
      <category>webdev</category>
      <category>i18n</category>
    </item>
    <item>
      <title>Spelling numbers in Arabic is harder than it looks: the four rules behind tafqeet</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Tue, 22 Sep 2026 22:03:39 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/spelling-numbers-in-arabic-is-harder-than-it-looks-the-four-rules-behind-tafqeet-3pgp</link>
      <guid>https://dev.to/support_confileo_ce7442eb/spelling-numbers-in-arabic-is-harder-than-it-looks-the-four-rules-behind-tafqeet-3pgp</guid>
      <description>&lt;p&gt;Most "number to words" libraries translate digits one at a time. That works in English and fails in Arabic on the very first cheque. I maintain an Arabic number-to-words converter (تفقيط) used for invoices and cheques, and these are the rules that broke every naive version I wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Units come before tens
&lt;/h2&gt;

&lt;p&gt;English says &lt;em&gt;twenty-five&lt;/em&gt;. Arabic says &lt;em&gt;five and twenty&lt;/em&gt;: &lt;strong&gt;خمسة وعشرون&lt;/strong&gt;. Any loop that walks the digits left to right and concatenates words produces «عشرون وخمسة», which a bank clerk reads as wrong at a glance.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Three to ten take the &lt;em&gt;opposite&lt;/em&gt; gender of the noun
&lt;/h2&gt;

&lt;p&gt;This is the rule everyone forgets. One and two agree with the counted noun; three to ten take the other gender:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;count&lt;/th&gt;
&lt;th&gt;masculine noun (جنيه)&lt;/th&gt;
&lt;th&gt;feminine noun (ليرة)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;جنيه واحد&lt;/td&gt;
&lt;td&gt;ليرة واحدة&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;ثلاثة جنيهات&lt;/td&gt;
&lt;td&gt;ثلاث ليرات&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;عشرة جنيهات&lt;/td&gt;
&lt;td&gt;عشر ليرات&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the words for 3–10 depend on the currency, not only on the digit. A converter that hard-codes «ثلاثة» is correct for pounds and wrong for lira.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The counted noun changes form with the last two digits
&lt;/h2&gt;

&lt;p&gt;This is the part that makes a lookup table impossible:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;last two digits of the amount&lt;/th&gt;
&lt;th&gt;form of the noun&lt;/th&gt;
&lt;th&gt;example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3–10&lt;/td&gt;
&lt;td&gt;plural&lt;/td&gt;
&lt;td&gt;ثلاثة &lt;strong&gt;آلاف&lt;/strong&gt;، سبعة &lt;strong&gt;جنيهات&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11–99&lt;/td&gt;
&lt;td&gt;singular, accusative (tanween)&lt;/td&gt;
&lt;td&gt;أحد عشر &lt;strong&gt;ألفاً&lt;/strong&gt;، خمسون &lt;strong&gt;جنيهاً&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;00 (100, 1,000…)&lt;/td&gt;
&lt;td&gt;singular&lt;/td&gt;
&lt;td&gt;مائة &lt;strong&gt;جنيه&lt;/strong&gt;، ألف &lt;strong&gt;جنيه&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The same rule applies to the scale words themselves, which is why 3,000 is «ثلاثة آلاف» but 11,000 is «أحد عشر ألفاً» and 100,000 is «مائة ألف».&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two hundred loses its ن in front of a noun
&lt;/h2&gt;

&lt;p&gt;«مائتان» on its own, but &lt;strong&gt;مائتا ألف&lt;/strong&gt; and &lt;strong&gt;مائتا جنيه&lt;/strong&gt; when a noun follows (the dual in a construct loses its final nun). A converter that emits «مائتان ألف» is one character off, and that character is exactly what a proof-reader looks for.&lt;/p&gt;

&lt;h2&gt;
  
  
  A version that gets these right
&lt;/h2&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;ONES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TEENS&lt;/span&gt; &lt;span class="o"&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="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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TENS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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="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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HUNDREDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&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="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="c1"&gt;// 1..999, masculine, nominative (the cheque convention)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;below1000&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;parts&lt;/span&gt; &lt;span class="o"&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&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;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HUNDREDS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;h&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&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;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&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="nx"&gt;ONES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;u&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="nx"&gt;TENS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&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="nx"&gt;TENS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;   &lt;span class="c1"&gt;// units before tens&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;TEENS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;else&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ONES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&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;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="c1"&gt;// Pick the noun form from the last two digits (rule 3), and fix مائتان (rule 4).&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;counted&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;forms&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="nx"&gt;k&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;one&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;k&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&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;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;two&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;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&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;noun&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;few&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;many&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;one&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;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&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="nf"&gt;below1000&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="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;words&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="nx"&gt;noun&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="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;THOUSAND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;one&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;two&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;few&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;many&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MILLION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;one&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;two&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;few&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;many&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tafqeet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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="nx"&gt;n&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;return&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1000&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;parts&lt;/span&gt; &lt;span class="o"&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="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;counted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MILLION&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;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;counted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;THOUSAND&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;rest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;below1000&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&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;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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="nf"&gt;tafqeet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// خمسة وعشرون&lt;/span&gt;
&lt;span class="nf"&gt;tafqeet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// ثلاثة آلاف&lt;/span&gt;
&lt;span class="nf"&gt;tafqeet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;11000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// أحد عشر ألفاً&lt;/span&gt;
&lt;span class="nf"&gt;tafqeet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// مائتا ألف&lt;/span&gt;
&lt;span class="nf"&gt;tafqeet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1250750&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// مليون ومائتان وخمسون ألفاً وسبعمائة وخمسون&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the integer part in the masculine. A real cheque line still needs the currency noun chosen by rule 3, the feminine table for feminine currencies, the fraction («وخمسون قرشاً»), and the framing banks expect: &lt;strong&gt;فقط ... لا غير&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your own numbers
&lt;/h2&gt;

&lt;p&gt;The code above is open source as &lt;a href="https://github.com/mahmoudQq2023/arabic-tafqeet" rel="noopener noreferrer"&gt;arabic-tafqeet&lt;/a&gt; (MIT, one file, tests included). The full version — pound, riyal, dirham, dinar and dollar, the fraction, the فقط/لا غير framing, and words back to digits — runs as a free converter: &lt;a href="https://confileo.com/ar/tafqeet-alarqam/" rel="noopener noreferrer"&gt;تفقيط الأرقام — Arabic number to words for cheques and invoices&lt;/a&gt;. There is an English &lt;a href="https://confileo.com/tools/number-to-words/" rel="noopener noreferrer"&gt;number to words&lt;/a&gt; tool on the same site if you need both languages on one invoice.&lt;/p&gt;

&lt;p&gt;If you find a number it spells wrong, tell me in the comments — that is how every rule above was found.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>i18n</category>
      <category>webdev</category>
      <category>arabic</category>
    </item>
    <item>
      <title>Por qué el texto invisible funciona en el nombre de TikTok y falla en el usuario de Instagram</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Wed, 09 Sep 2026 20:11:19 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/por-que-el-texto-invisible-funciona-en-el-nombre-de-tiktok-y-falla-en-el-usuario-de-instagram-47l6</link>
      <guid>https://dev.to/support_confileo_ce7442eb/por-que-el-texto-invisible-funciona-en-el-nombre-de-tiktok-y-falla-en-el-usuario-de-instagram-47l6</guid>
      <description>&lt;p&gt;Cada tanto vuelve la misma pregunta con otro disfraz: &lt;em&gt;cómo poner el nombre invisible en TikTok&lt;/em&gt;, &lt;em&gt;cómo mandar un mensaje vacío en WhatsApp&lt;/em&gt;. En 2026 la de moda es la de TikTok, y casi ningún video explica el mecanismo. Va aquí, desde el código.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un espacio no es un carácter: para la app es un separador
&lt;/h2&gt;

&lt;p&gt;Cuando escribes un nombre o un mensaje, la app lo recorta antes de guardarlo: &lt;code&gt;trim()&lt;/code&gt; quita los espacios de los extremos, las secuencias se colapsan y un campo con &lt;em&gt;solo&lt;/em&gt; espacios queda vacío. Después corre la validación: nombre vacío, rechazado.&lt;/p&gt;

&lt;p&gt;Por eso el texto invisible no puede estar hecho de espacios, sino de caracteres que &lt;strong&gt;se dibujan como nada, pero que para el código de recorte no son espacio en blanco&lt;/strong&gt;. Unicode tiene varios, ninguno diseñado para esto, y cada app los trata distinto.&lt;/p&gt;

&lt;h2&gt;
  
  
  Los cinco que importan
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Carácter&lt;/th&gt;
&lt;th&gt;Code point&lt;/th&gt;
&lt;th&gt;Para qué existe&lt;/th&gt;
&lt;th&gt;Quién lo conserva&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;Relleno de sílabas coreanas: una "letra" que no dibuja nada&lt;/td&gt;
&lt;td&gt;Nombre y bio de TikTok e Instagram; nick de Free Fire y PUBG Mobile&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;La celda braille sin puntos: un glifo imprimible y vacío&lt;/td&gt;
&lt;td&gt;Mensajes de WhatsApp, Telegram y Discord; comentarios de Instagram&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;Punto de corte de línea sin ancho&lt;/td&gt;
&lt;td&gt;X, Notas de iPhone, HTML; la mayoría de los chats lo elimina&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;Límite invisible entre elementos&lt;/td&gt;
&lt;td&gt;Facebook y 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;El espacio tipográfico más delgado&lt;/td&gt;
&lt;td&gt;Word, Google Docs, correo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Hangul Filler es una letra&lt;/strong&gt; (categoría &lt;code&gt;Lo&lt;/code&gt;): un validador que pregunta "¿tiene al menos una letra?" responde que sí. &lt;strong&gt;Braille Blank es un símbolo&lt;/strong&gt; (&lt;code&gt;So&lt;/code&gt;), imprimible, así que "el mensaje no puede estar vacío" pasa. &lt;strong&gt;Zero-Width Space es un carácter de formato&lt;/strong&gt; (&lt;code&gt;Cf&lt;/code&gt;), y los caracteres de formato son lo primero que borra un sanitizador; por eso falla en WhatsApp y funciona en un editor de texto.&lt;/p&gt;

&lt;h2&gt;
  
  
  El truco de TikTok, explicado en un párrafo
&lt;/h2&gt;

&lt;p&gt;El "nombre invisible en TikTok 2026" funciona así: el nombre para mostrar de TikTok es un campo de texto libre que valida "no vacío", no "solo letras y números". Pegas un &lt;code&gt;U+3164&lt;/code&gt;, el campo recibe una letra, &lt;code&gt;trim()&lt;/code&gt; no la toca porque no es espacio en blanco, y el servidor guarda un nombre que el renderizador dibuja como nada. Lo único nuevo frente a los nicks de Free Fire es el video. El mismo carácter funciona en el nombre y en las líneas de la bio de Instagram, pero &lt;strong&gt;no en el nombre de usuario (@handle)&lt;/strong&gt;: ese campo acepta únicamente letras, dígitos, puntos y guiones bajos, y ahí no hay blanco que sobreviva.&lt;/p&gt;

&lt;h2&gt;
  
  
  Por qué un nombre invisible se rompe tras una actualización
&lt;/h2&gt;

&lt;p&gt;Un juego que antes aceptaba &lt;code&gt;U+3164&lt;/code&gt; y ahora lo rechaza no "arregló los nombres invisibles": alguien agregó normalización Unicode o un filtro por categoría al validador, casi siempre para frenar suplantaciones con nombres idénticos a la vista. En cuanto corre &lt;code&gt;NFKC&lt;/code&gt;, &lt;code&gt;U+3164&lt;/code&gt; se convierte en &lt;code&gt;U+1160&lt;/code&gt; (Hangul Jungseong Filler), y un filtro por categoría lo elimina del todo; &lt;code&gt;U+FFA0&lt;/code&gt; (la forma de medio ancho) se convierte en lo mismo. Roblox es el ejemplo típico: elimina la mayoría de los rellenos tras cada actualización. Y como en Free Fire cambiar de nombre cuesta 390 diamantes o una tarjeta, prueba en una cuenta secundaria antes de gastar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detectarlo es trivial
&lt;/h2&gt;

&lt;p&gt;Invisible no significa oculto: cualquier cosa que cuente caracteres lo delata:&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;INVISIBLES&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;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reporte&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;invisibles&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;INVISIBLES&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;invisibles&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;invisibles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;utf16&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="p"&gt;};&lt;/span&gt;

&lt;span class="nf"&gt;reporte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anaㅤ⠀&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: 5, invisibles: 2, utf16: 5 }&lt;/span&gt;
&lt;span class="nf"&gt;reporte&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="c1"&gt;// { total: 5, invisibles: 2, utf16: 8 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos detalles que muerden. &lt;code&gt;.length&lt;/code&gt; cuenta unidades UTF-16, no caracteres: un emoji fuera del BMP vale 2 y el de la familia vale 8 (tres personas unidas por dos &lt;code&gt;U+200D&lt;/code&gt;, que también caen en la lista: segmenta en grafemas con &lt;code&gt;Intl.Segmenter&lt;/code&gt; antes de filtrar). Itera siempre con &lt;code&gt;for…of&lt;/code&gt;. Y normaliza &lt;strong&gt;después&lt;/strong&gt; de contar: &lt;code&gt;s.normalize('NFKC')&lt;/code&gt; convierte &lt;code&gt;U+3164&lt;/code&gt; y &lt;code&gt;U+FFA0&lt;/code&gt; en &lt;code&gt;U+1160&lt;/code&gt; (que no está en la lista) y el espacio fino en un espacio normal: el contador deja de verlos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dónde conseguir el carácter correcto
&lt;/h2&gt;

&lt;p&gt;Un teclado no tiene tecla para &lt;code&gt;U+3164&lt;/code&gt;; se copia de una página que lo tenga listo. La versión en español de &lt;a href="https://confileo.com/es/texto-invisible/" rel="noopener noreferrer"&gt;texto invisible para copiar y pegar&lt;/a&gt; ofrece los cinco caracteres de la tabla con la app que conserva cada uno. La referencia en inglés, con entidades HTML, escapes CSS y una casilla que comprueba si lo que pegaste contiene un carácter invisible o solo espacios, está en &lt;a href="https://confileo.com/tools/invisible-text/" rel="noopener noreferrer"&gt;la página de invisible text&lt;/a&gt;. Todo corre en el navegador: es un &lt;code&gt;navigator.clipboard.writeText&lt;/code&gt; de una cadena que la página ya contiene.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resumen práctico
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mensaje&lt;/strong&gt; en blanco en WhatsApp, Telegram o Discord: &lt;code&gt;U+2800&lt;/code&gt;. WhatsApp y Telegram eliminan &lt;code&gt;U+200B&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nombre&lt;/strong&gt; en TikTok, Instagram, Free Fire o PUBG Mobile, o línea vacía de la bio en TikTok e Instagram: &lt;code&gt;U+3164&lt;/code&gt; (respaldo: &lt;code&gt;U+FFA0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publicación&lt;/strong&gt; en blanco en X o línea oculta en Notas de iPhone: &lt;code&gt;U+200B&lt;/code&gt;; iMessage lo rechaza si va solo, ahí usa &lt;code&gt;U+2800&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Facebook y Messenger: &lt;code&gt;U+2063&lt;/code&gt;. Documentos y correo: &lt;code&gt;U+200A&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Nada funciona en un campo que acepta solo &lt;code&gt;[a-z0-9._]&lt;/code&gt;: el nombre de usuario de Instagram y el de Discord.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Si mantienes un campo de nombre y quieres frenar esto: normaliza con NFKC y rechaza lo que quede vacío tras quitar &lt;code&gt;\p{Cf}&lt;/code&gt;, &lt;code&gt;\p{Zs}&lt;/code&gt; y los dos rellenos Hangul. Y dilo en el mensaje de error, porque "nombre inválido" manda a la gente directo a las páginas de arriba.&lt;/p&gt;

</description>
      <category>unicode</category>
      <category>webdev</category>
      <category>spanish</category>
      <category>javascript</category>
    </item>
    <item>
      <title>"Unix timestamp to Excel date: why serial 60 is a day that never existed"</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Mon, 07 Sep 2026 01:00:41 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/unix-timestamp-to-excel-date-why-serial-60-is-a-day-that-never-existed-30dm</link>
      <guid>https://dev.to/support_confileo_ce7442eb/unix-timestamp-to-excel-date-why-serial-60-is-a-day-that-never-existed-30dm</guid>
      <description>&lt;p&gt;You paste &lt;code&gt;1756771200&lt;/code&gt; into a spreadsheet and want to see a date. Or you export a sheet and your API receives a column of numbers like &lt;code&gt;46236.5&lt;/code&gt; instead of dates. Both directions look trivial and both go wrong in the same three places: the epoch, the unit, and one fake day in February 1900.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two numbers actually count
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Unix timestamp&lt;/strong&gt; is the number of seconds since 1 January 1970, 00:00 UTC. Leap seconds are ignored, so every day is exactly 86,400 seconds long. A timestamp from today has 10 digits. If you see 13 digits it is milliseconds (what &lt;code&gt;Date.now()&lt;/code&gt; returns in JavaScript). 16 digits is microseconds, 19 digits is nanoseconds (Go, Rust and several databases use those).&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;Excel serial date&lt;/strong&gt; is the number of days since Excel's epoch, with the fraction of the day as the decimal part. &lt;code&gt;46236.5&lt;/code&gt; is noon on the day with serial 46236. Excel never stores a time zone; the number is whatever you typed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The formulas
&lt;/h2&gt;

&lt;p&gt;Unix seconds to Excel serial, then format the cell as a date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=A1/86400 + 25569
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;25569&lt;/code&gt; is the serial of 1 January 1970. For milliseconds divide by &lt;code&gt;86400000&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;Excel serial to Unix seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=(A1 - 25569) * 86400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both formulas produce UTC. Excel does not know where you are, so a timestamp from a server will show the UTC hour. Add &lt;code&gt;+ 2/24&lt;/code&gt; to shift to UTC+2, &lt;code&gt;- 5/24&lt;/code&gt; for UTC-5, and so on. If a whole column is off by exactly the same number of hours, that is the cause, not the formula.&lt;/p&gt;

&lt;p&gt;In a German, Spanish or French copy of Excel the arithmetic is identical, but the function names and separators are not: &lt;code&gt;DATE(1970,1,1)&lt;/code&gt; becomes &lt;code&gt;DATUM(1970;1;1)&lt;/code&gt;, &lt;code&gt;FECHA(1970;1;1)&lt;/code&gt; or &lt;code&gt;DATE(1970;1;1)&lt;/code&gt; with semicolons. The formula above avoids the function entirely, which is why it travels between language versions without editing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serial 60: the day that never happened
&lt;/h2&gt;

&lt;p&gt;Excel's default 1900 date system says serial 1 is 1 January 1900 and serial 60 is 29 February 1900. That date does not exist. 1900 is divisible by 100 but not by 400, so it was not a leap year. The bug comes from Lotus 1-2-3, and Excel reproduced it in 1987 so that spreadsheets imported from Lotus would keep their dates. It has been kept ever since, on purpose, because fixing it would move every date in every old file by one day.&lt;/p&gt;

&lt;p&gt;The consequences are small but real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every serial below 61 is one day off against the real calendar.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DATE(1900,2,29)&lt;/code&gt; is accepted instead of raising an error.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WEEKDAY&lt;/code&gt; returns the wrong day for dates in January and February 1900.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also why libraries such as pandas use &lt;strong&gt;30 December 1899&lt;/strong&gt; as "day zero" when they read Excel files: counting from there absorbs the phantom day, and every date from 1 March 1900 onward comes out right.&lt;/p&gt;

&lt;p&gt;Google Sheets and LibreOffice count from 30 December 1899 without the fake leap day. Their serials agree with Excel from 1 March 1900 onward and differ by one before that. Older Mac versions of Excel used a &lt;strong&gt;1904 date system&lt;/strong&gt; (day zero is 1 January 1904). A workbook created there and opened elsewhere shows every date 1,462 days off, which is four years and one day. The fix is in File → Options → Advanced → "Use 1904 date system", not in the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unit mistakes you will actually see
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A 13-digit value in a seconds converter&lt;/strong&gt; produces a date thousands of years in the future. Count the digits before you convert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 19-digit nanosecond stamp does not fit in a JavaScript number.&lt;/strong&gt; Doubles hold integers exactly only up to 2^53, about 9.0 × 10^15, so the last digits get rounded. Treat it as a string and split off the last nine digits as the fraction of a second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;19 January 2038, 03:14:07 UTC&lt;/strong&gt; is where a signed 32-bit seconds counter overflows. Anything still storing timestamps in an &lt;code&gt;int32&lt;/code&gt; will wrap to December 1901 at that moment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excel's own limit&lt;/strong&gt; is serial 2,958,465, which is 31 December 9999. Anything above shows as &lt;code&gt;#####&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Doing it without a formula
&lt;/h2&gt;

&lt;p&gt;If you have a column of timestamps rather than one value, the &lt;a href="https://confileo.com/tools/unix-timestamp-converter/" rel="noopener noreferrer"&gt;Unix timestamp converter&lt;/a&gt; takes the whole column at once. It detects seconds, milliseconds, microseconds and nanoseconds from the digit count, prints UTC and your local time on separate lines, gives the Excel serial and the ISO 8601 form for each row, flags serials below 61 because of the phantom day above, and writes the formula in the language of the Excel you use. Nothing is uploaded; the arithmetic runs in the browser.&lt;/p&gt;

&lt;p&gt;Going the other way, if what you have is a date and you need the number, the same page converts an Excel serial or an ISO date back to a timestamp in any of the four units.&lt;/p&gt;

</description>
      <category>excel</category>
      <category>javascript</category>
      <category>datetime</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A text diff that reads .docx and .pdf in the browser, and the trap that makes every PDF look 100% changed</title>
      <dc:creator>Mahmoud Ibrahim — Confileo</dc:creator>
      <pubDate>Sat, 05 Sep 2026 11:38:09 +0000</pubDate>
      <link>https://dev.to/support_confileo_ce7442eb/a-text-diff-that-reads-docx-and-pdf-in-the-browser-and-the-trap-that-makes-every-pdf-look-100-3fai</link>
      <guid>https://dev.to/support_confileo_ce7442eb/a-text-diff-that-reads-docx-and-pdf-in-the-browser-and-the-trap-that-makes-every-pdf-look-100-3fai</guid>
      <description>&lt;p&gt;I maintain a small &lt;a href="https://confileo.com/tools/text-diff/" rel="noopener noreferrer"&gt;online text diff&lt;/a&gt; that&lt;br&gt;
people mostly use for contracts, price lists and CVs, not code. Its search traffic&lt;br&gt;
told me something I had not designed for: the queries were &lt;em&gt;"compare two Word&lt;br&gt;
documents"&lt;/em&gt;, &lt;em&gt;"compare 2 text files online"&lt;/em&gt;, &lt;em&gt;"compare two PDF documents"&lt;/em&gt;.&lt;br&gt;
Nobody arrives holding two strings. They arrive holding two files.&lt;/p&gt;

&lt;p&gt;This post is the handful of decisions that turned a textbook LCS exercise into&lt;br&gt;
something that survives real documents. None of it is clever; all of it was&lt;br&gt;
learned by watching the tool be wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two levels, not one
&lt;/h2&gt;

&lt;p&gt;A diff over whole lines is what &lt;code&gt;git diff&lt;/code&gt; shows you and it is the right first&lt;br&gt;
level: it keeps paragraphs aligned, and a moved clause shows up as one removal&lt;br&gt;
and one addition instead of a cloud of red and green words.&lt;/p&gt;

&lt;p&gt;But a contract edit is usually a single number inside a long line. A line-only&lt;br&gt;
diff paints the whole line red and the whole replacement green, and the reader&lt;br&gt;
still has to find the number by eye — which is the exact task the tool exists&lt;br&gt;
to remove.&lt;/p&gt;

&lt;p&gt;So the second level runs &lt;em&gt;only&lt;/em&gt; on lines the first level paired as "modified":&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="c1"&gt;// Level 1: LCS over lines -&amp;gt; ops of keep / remove / add&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computeLCS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leftLines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rightLines&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Adjacent removes and adds are paired positionally as "modified" lines,&lt;/span&gt;
&lt;span class="c1"&gt;// and each pair gets a Level 2 diff over its words.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;removes&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;adds&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="k"&gt;for &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;p&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;p&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;lw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rw&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wordDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leftLines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;removes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="nx"&gt;rightLines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;adds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]]);&lt;/span&gt;
  &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;modified&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;leftWords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rightWords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&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;The pairing rule is deliberately dumb: the &lt;em&gt;k&lt;/em&gt;-th removed line in a block is&lt;br&gt;
matched with the &lt;em&gt;k&lt;/em&gt;-th added line. A smarter pairing (best similarity across&lt;br&gt;
the block) sounds better and is worse in practice on prose, because a block of&lt;br&gt;
three rewritten sentences is almost always three edits in order, and similarity&lt;br&gt;
scoring will happily pair sentence 1 with sentence 3 because they share the&lt;br&gt;
word "the".&lt;/p&gt;

&lt;p&gt;Word tokenising keeps whitespace as its own tokens:&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;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&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;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\S&lt;/span&gt;&lt;span class="sr"&gt;+|&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+&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="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks pointless until you render: if you drop the spaces, you cannot&lt;br&gt;
reconstruct the line, and a diff you cannot reconstruct is a diff you cannot&lt;br&gt;
copy out.&lt;/p&gt;
&lt;h2&gt;
  
  
  LCS, not Myers, and the number that actually matters
&lt;/h2&gt;

&lt;p&gt;Every diff article eventually says "use Myers' O(ND) algorithm". For a tool that&lt;br&gt;
runs on someone's phone against two 40-page contracts, I did not, and here is&lt;br&gt;
the honest reasoning.&lt;/p&gt;

&lt;p&gt;The plain dynamic-programming LCS is O(m·n) in time &lt;em&gt;and memory&lt;/em&gt;. Two 2,000-line&lt;br&gt;
documents are 4 million cells — fine. Two 5,000-line exports are 25 million&lt;br&gt;
cells of a &lt;code&gt;number[][]&lt;/code&gt;, which on a mid-range Android phone is a tab crash, not&lt;br&gt;
a slow result. Myers fixes the &lt;em&gt;typical&lt;/em&gt; case but its worst case is the same&lt;br&gt;
shape, and the failure mode is identical: the browser dies with no message.&lt;/p&gt;

&lt;p&gt;So the thing that made the tool reliable was not the algorithm. It was a guard:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;simpleDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;left&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;right&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// positional, O(max(m, n)), always finishes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Above 5M cells it degrades to a line-by-line positional compare. That produces&lt;br&gt;
a &lt;em&gt;worse&lt;/em&gt; diff for large inputs with insertions near the top (everything after&lt;br&gt;
the insertion is offset by one and reads as changed). But it produces one, in&lt;br&gt;
milliseconds, and the visitor can narrow the selection and try again. A diff&lt;br&gt;
tool that sometimes shows nothing is worth less than one that is sometimes&lt;br&gt;
coarse. The same cap exists inside the word level at 500K cells, where the&lt;br&gt;
fallback is "whole line removed, whole line added".&lt;/p&gt;

&lt;p&gt;If your inputs are code and your users are on laptops, Myers is the right call.&lt;br&gt;
If your inputs are documents and your users are on phones, put the guard in&lt;br&gt;
first and pick the algorithm second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the files without a server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.docx&lt;/code&gt; and &lt;code&gt;.xlsx&lt;/code&gt; are ZIP containers. &lt;code&gt;.pdf&lt;/code&gt; is something every modern browser&lt;br&gt;
already knows how to render. That means extraction can happen entirely&lt;br&gt;
client-side, and for this particular tool that is not a nice-to-have: the&lt;br&gt;
documents people most often need to compare are contracts, payslips, offers and&lt;br&gt;
medical reports. A comparison that requires uploading both versions is not one&lt;br&gt;
they can honestly run on those files.&lt;/p&gt;

&lt;p&gt;Three things that bit me, in order of how much support mail they generated:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scanned PDFs have no text.&lt;/strong&gt; A scan is a picture of a page. The extraction&lt;br&gt;
"succeeds" and returns an empty string, and a naive tool then reports the&lt;br&gt;
document as identical to an empty box. Detect the empty result and say the word&lt;br&gt;
&lt;em&gt;OCR&lt;/em&gt; to the user, or they will assume the tool is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not everything is UTF-8.&lt;/strong&gt; A &lt;code&gt;.txt&lt;/code&gt; exported from an older Windows app arrives&lt;br&gt;
in Windows-1252, and decoding it as UTF-8 turns every &lt;code&gt;é&lt;/code&gt; and &lt;code&gt;–&lt;/code&gt; into a&lt;br&gt;
replacement character — which then shows up as a &lt;em&gt;difference&lt;/em&gt; between two files&lt;br&gt;
that are byte-for-byte the same. Fall back to Windows-1252 when UTF-8 decoding&lt;br&gt;
produces replacement characters, and tell the user you did, so they check the&lt;br&gt;
accented characters themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Size.&lt;/strong&gt; I cap at 8 MB per side. Not because the ZIP or PDF parsers cannot&lt;br&gt;
handle more, but because the LCS guard above is line-count based, and an&lt;br&gt;
enormous file is the one input that will blow past every guard at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: PDF text is wrapped
&lt;/h2&gt;

&lt;p&gt;This is the one I would put at the top of the page if I could only keep one&lt;br&gt;
sentence.&lt;/p&gt;

&lt;p&gt;Text extracted from a PDF carries a line break wherever the &lt;em&gt;layout&lt;/em&gt; broke the&lt;br&gt;
line — mid-sentence, after every 80-odd characters. Text from the &lt;code&gt;.docx&lt;/code&gt; the PDF&lt;br&gt;
was exported from carries a line break only at paragraph ends. Compare the two,&lt;br&gt;
and a line-based diff reports &lt;strong&gt;every paragraph as changed&lt;/strong&gt;, because no line&lt;br&gt;
on the left is equal to any line on the right, even though not a word differs.&lt;/p&gt;

&lt;p&gt;You can partially rescue it by re-joining lines that do not end in sentence&lt;br&gt;
punctuation before you compare — a preprocessing step, and one I have not&lt;br&gt;
automated, because it guesses wrong on lists, addresses and table cells. The&lt;br&gt;
"ignore whitespace" option does something narrower and safer: it collapses&lt;br&gt;
runs of spaces and tabs &lt;em&gt;within&lt;/em&gt; a line, which fixes the double-space and&lt;br&gt;
tab-versus-spaces noise a PDF exporter adds, but cannot undo a line break.&lt;br&gt;
There is no fully general fix, because a PDF also breaks lines at the ends of&lt;br&gt;
table cells and around headers and footers, and reflowing those back into&lt;br&gt;
prose is a layout-analysis problem, not a string problem. The honest answer&lt;br&gt;
on the page is: &lt;em&gt;compare paragraph by paragraph rather than pasting the whole&lt;br&gt;
document at once&lt;/em&gt;, and the output becomes far more useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would keep if I rewrote it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two levels: lines first, words only inside modified pairs.&lt;/li&gt;
&lt;li&gt;Positional pairing of removes with adds. Boring and right for prose.&lt;/li&gt;
&lt;li&gt;A cell-count guard &lt;em&gt;before&lt;/em&gt; any algorithm choice.&lt;/li&gt;
&lt;li&gt;Client-side extraction, with explicit messages for the empty-scan and
wrong-encoding cases.&lt;/li&gt;
&lt;li&gt;One sentence about PDF line wrapping, above the fold.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The running tool is at&lt;br&gt;
&lt;a href="https://confileo.com/tools/text-diff/" rel="noopener noreferrer"&gt;confileo.com/tools/text-diff&lt;/a&gt; if you&lt;br&gt;
want to see how the modified-line rendering reads with real documents. It does&lt;br&gt;
not store the text, which is the whole point when the documents are contracts.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>typescript</category>
      <category>algorithms</category>
      <category>webdev</category>
    </item>
    <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>
