<rss version="2.0">
  <channel>
    <title>Text on LLBBL Blog</title>
    <link>https://llbbl.blog/categories/text/</link>
    <description></description>
    
    <language>en</language>
    
    <lastBuildDate>Sat, 15 Aug 2026 10:00:00 -0500</lastBuildDate>
    
    <item>
      <title>There Is No Such Thing as a Text File</title>
      <link>https://llbbl.blog/2026/08/15/there-is-no-such-thing.html</link>
      <pubDate>Sat, 15 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/15/there-is-no-such-thing.html</guid>
      <description>&lt;p&gt;Last time I took apart PNG, which opens it&amp;rsquo;s file with eight bytes whose entire job is to announce &amp;ldquo;I am a PNG&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;A text file opens with nothing. No signature, no header, no length field, no version, no metadata. It is bytes, and then it stops.&lt;/p&gt;
&lt;p&gt;So this post is the opposite of the last one. Instead of walking a structure, we&amp;rsquo;re going to look at what happens when there isn&amp;rsquo;t one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;posix-says-it-out-loud&#34;&gt;POSIX Says It Out Loud&lt;/h2&gt;
&lt;p&gt;Start with the standard. POSIX defines a text file in §3.403:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A file that contains characters organized into zero or more lines. The lines do not contain NUL characters and none can exceed {LINE_MAX} bytes in length, including the &lt;code&gt;&amp;lt;newline&amp;gt;&lt;/code&gt; character. &lt;strong&gt;Although POSIX.1-2017 does not distinguish between text files and binary files&lt;/strong&gt; (see the ISO C standard), many utilities only produce predictable or meaningful output when operating on text files.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Read that bolded part again. The standard defines the term and then tells you the system doesn&amp;rsquo;t enforce it.&lt;/p&gt;
&lt;p&gt;Nothing in the filesystem records &amp;ldquo;this is text.&amp;rdquo; There&amp;rsquo;s no flag on the inode, no attribute, nothing in the directory entry. The &lt;code&gt;.txt&lt;/code&gt; extension is a hint to humans and to Windows. &amp;ldquo;Text file&amp;rdquo; is not a property a file has. It&amp;rsquo;s a claim the &lt;em&gt;reader&lt;/em&gt; makes about the bytes, and every tool makes it slightly differently.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-bytes-dont-know-what-they-mean&#34;&gt;The Bytes Don&amp;rsquo;t Know What They Mean&lt;/h2&gt;
&lt;p&gt;A file stores bytes. Turning bytes into characters requires an encoding, and the encoding is not in the file.&lt;/p&gt;
&lt;p&gt;Here are the same five characters, &lt;code&gt;Héllo&lt;/code&gt;, in several encodings:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;ascii         FAILS: ordinal not in range(128)
latin-1        5 bytes  48 e9 6c 6c 6f
cp1252         5 bytes  48 e9 6c 6c 6f
utf-8          6 bytes  48 c3 a9 6c 6c 6f
utf-16        12 bytes  ff fe 48 00 e9 00 6c 00 6c 00 6f 00
utf-16-be     10 bytes  00 48 00 e9 00 6c 00 6c 00 6f
utf-32        24 bytes  ff fe 00 00 48 00 00 00 e9 00 00 00 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Five characters. Anywhere from 5 to 24 bytes. Nothing in any of those files says which one you&amp;rsquo;re looking at.&lt;/p&gt;
&lt;p&gt;When you open a file in your editor and it looks right, that&amp;rsquo;s your editor guessing correctly. When you get &lt;code&gt;caf√©&lt;/code&gt; instead of &lt;code&gt;café&lt;/code&gt;, that&amp;rsquo;s your editor guessing wrong. The file never changed.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-fight-over-the-eighth-bit&#34;&gt;The Fight Over the Eighth Bit&lt;/h2&gt;
&lt;p&gt;ASCII was standardized as a 7-bit code: 128 values, &lt;code&gt;0x00&lt;/code&gt; through &lt;code&gt;0x7F&lt;/code&gt;. Thirty-three control codes, ninety-five printable characters, and that was the whole world if the world spoke English.&lt;/p&gt;
&lt;p&gt;Bytes have eight bits though, so there were another 128 values sitting there unused. Everyone grabbed them, and everyone grabbed them differently.&lt;/p&gt;
&lt;p&gt;ISO 8859-1 (Latin-1) claimed &lt;code&gt;0xA0&lt;/code&gt;–&lt;code&gt;0xFF&lt;/code&gt; for Western European letters and reserved &lt;code&gt;0x80&lt;/code&gt;–&lt;code&gt;0x9F&lt;/code&gt; for a second set of control codes nobody used. Microsoft looked at those 32 wasted slots and put printable punctuation there instead, creating Windows-1252. That&amp;rsquo;s where the curly quotes and the em dash live:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;CP1252 text  : It&amp;#39;s &amp;#34;fine&amp;#34; — really
CP1252 bytes : 49 74 27 73 20 93 66 69 6e 65 94 20 97 20 72 65 61 6c 6c 79
UTF-8 bytes  : 49 74 27 73 20 e2 80 9c 66 69 6e 65 e2 80 9d 20 e2 80 94 ...
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Byte &lt;code&gt;0x93&lt;/code&gt; is a left curly quote in CP1252 and a control character in strict Latin-1. This is why pasting from Word into a system expecting Latin-1 produces garbage: the bytes are legal, they just mean nothing there.&lt;/p&gt;
&lt;p&gt;Mojibake is exactly this, and it&amp;rsquo;s completely deterministic:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;original text  : café
as UTF-8 bytes : 63 61 66 c3 a9
read as CP1252 : cafÃ©
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;c3 a9&lt;/code&gt; is one character in UTF-8 and two characters in CP1252. Both readings are valid. Only one is what you meant.&lt;/p&gt;
&lt;p&gt;It could have been worse. IBM&amp;rsquo;s EBCDIC, still running on mainframes, isn&amp;rsquo;t an ASCII superset at all:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;&amp;#39;A&amp;#39;  ASCII 0x41   EBCDIC 0xc1
&amp;#39;a&amp;#39;  ASCII 0x61   EBCDIC 0x81
&amp;#39; &amp;#39;  ASCII 0x20   EBCDIC 0x40
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And the letters aren&amp;rsquo;t even contiguous. &lt;code&gt;I&lt;/code&gt; is &lt;code&gt;0xc9&lt;/code&gt;, &lt;code&gt;J&lt;/code&gt; is &lt;code&gt;0xd1&lt;/code&gt;, with a gap in between. Sorting strings by byte value, which works fine in ASCII, silently produces wrong output in EBCDIC.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;why-utf-8-won&#34;&gt;Why UTF-8 Won&lt;/h2&gt;
&lt;p&gt;UTF-8 encodes a character in one to four bytes. ASCII characters keep their single-byte values, so every ASCII file is already a valid UTF-8 file. That backward compatibility gets most of the credit, but the more interesting property is the bit pattern:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;&amp;#39;A&amp;#39;  U+0041   1 byte   41           01000001
&amp;#39;é&amp;#39;  U+00E9   2 bytes  c3 a9        11000011 10101001
&amp;#39;€&amp;#39;  U+20AC   3 bytes  e2 82 ac     11100010 10000010 10101100
&amp;#39;🙂&amp;#39; U+1F642  4 bytes  f0 9f 99 82  11110000 10011111 10011001 10000010
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Look at the leading bits. A single-byte character starts with &lt;code&gt;0&lt;/code&gt;. A multi-byte character starts with &lt;code&gt;110&lt;/code&gt;, &lt;code&gt;1110&lt;/code&gt;, or &lt;code&gt;11110&lt;/code&gt;, where the number of leading 1s is the total byte count. Every continuation byte starts with &lt;code&gt;10&lt;/code&gt;, and nothing else does.&lt;/p&gt;
&lt;p&gt;That makes UTF-8 self-synchronizing. Drop into the middle of a file at a random offset and you can tell immediately whether you&amp;rsquo;re mid-character, and walk backwards a byte or two to find the boundary. You do not need to have read the file from the beginning.&lt;/p&gt;
&lt;p&gt;Compare that to UTF-16, where you must know the byte order and must have tracked whether you&amp;rsquo;re on an even or odd boundary. UTF-8 made encoding a local property instead of a global one, and that&amp;rsquo;s why it took over.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-bom&#34;&gt;The BOM&lt;/h2&gt;
&lt;p&gt;Multi-byte encodings have a byte order problem: is &lt;code&gt;00 48&lt;/code&gt; the character &lt;code&gt;U+0048&lt;/code&gt; or &lt;code&gt;U+4800&lt;/code&gt;? The Byte Order Mark solves it by putting &lt;code&gt;U+FEFF&lt;/code&gt; at the start of the file, so a reader can look at the first two bytes and work out the endianness.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;utf-16      ff fe 68 69 ...   (little-endian)
utf-16-le   68 00 69 00       (no BOM, you&amp;#39;d better know)
utf-8-sig   ef bb bf 68 69    (UTF-8 &amp;#34;BOM&amp;#34;)
utf-8       68 69             (no BOM)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;UTF-8 has no byte order to mark, because its unit is one byte. The UTF-8 BOM is not a byte order mark at all; it&amp;rsquo;s a three-byte flag saying &amp;ldquo;this is UTF-8,&amp;rdquo; and the Unicode Consortium neither requires nor recommends it.&lt;/p&gt;
&lt;p&gt;It also actively breaks things. The kernel identifies a script by looking for &lt;code&gt;0x23 0x21&lt;/code&gt;, the characters &lt;code&gt;#!&lt;/code&gt;, at offset zero:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;no BOM  : 23 21 2f 62 69 6e 2f 73 68 0a  -&amp;gt;  #!/bin/sh
with BOM: ef bb bf 23 21 2f 62 69 6e 2f  -&amp;gt;  not a script
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Same for JSON parsers, CSV importers, and anything else that expects a specific first byte. If you have ever seen a shell script fail with a cryptic error on a line that looks correct, this is a candidate.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;lines-are-a-convention-too&#34;&gt;Lines Are a Convention Too&lt;/h2&gt;
&lt;p&gt;There is no line structure in a text file. There&amp;rsquo;s a byte that tools agree means &amp;ldquo;line break,&amp;rdquo; and even that isn&amp;rsquo;t agreed on.&lt;/p&gt;
&lt;p&gt;The split is a hardware inheritance. A teletype needed two separate mechanical actions to start a new line: &lt;strong&gt;carriage return&lt;/strong&gt; (&lt;code&gt;0x0D&lt;/code&gt;) moved the print head back to the left margin, and &lt;strong&gt;line feed&lt;/strong&gt; (&lt;code&gt;0x0A&lt;/code&gt;) advanced the paper by one row. Two actions, two control codes.&lt;/p&gt;
&lt;p&gt;Then everyone picked differently. Unix chose LF alone. MS-DOS, and Windows after it, kept both as CRLF. Classic Mac OS used CR alone. Those choices are still with us thirty years later, and they&amp;rsquo;re the reason &lt;code&gt;.gitattributes&lt;/code&gt; exists.&lt;/p&gt;
&lt;p&gt;And then there&amp;rsquo;s the trailing newline, which people argue about without realizing the standard already answered it. POSIX §3.206:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A line is a sequence of zero or more non-&lt;code&gt;&amp;lt;newline&amp;gt;&lt;/code&gt; characters &lt;strong&gt;plus a terminating &lt;code&gt;&amp;lt;newline&amp;gt;&lt;/code&gt; character&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The newline is part of the line, not a separator between lines. A file whose last byte isn&amp;rsquo;t a newline doesn&amp;rsquo;t have a final line. POSIX §3.195 has a name for what it has instead: an &lt;em&gt;incomplete line&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;That definition has teeth:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ wc -l lf.txt nofinal.txt
       2 lf.txt
       1 nofinal.txt
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Both files contain the text &lt;code&gt;one&lt;/code&gt; and &lt;code&gt;two&lt;/code&gt;. The first ends with a newline, the second doesn&amp;rsquo;t. &lt;code&gt;wc -l&lt;/code&gt; counts newline bytes, so the second file reports one line despite visibly having two.&lt;/p&gt;
&lt;p&gt;This is also what git&amp;rsquo;s &lt;code&gt;\ No newline at end of file&lt;/code&gt; marker means. It isn&amp;rsquo;t a style complaint. Git is telling you the last line is incomplete by the POSIX definition, which matters because otherwise appending a line would silently modify the existing last line rather than adding a new one.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;how-tools-guess&#34;&gt;How Tools Guess&lt;/h2&gt;
&lt;p&gt;Since nothing declares itself, every tool that needs to know applies a heuristic. The dominant one is: &lt;strong&gt;does it contain a NUL byte?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That test exists because C strings are NUL-terminated, so a NUL in the middle of what claims to be text means something is off. It&amp;rsquo;s a good heuristic. It&amp;rsquo;s also wrong in two ways worth knowing about.&lt;/p&gt;
&lt;p&gt;Git&amp;rsquo;s version is &lt;code&gt;buffer_is_binary()&lt;/code&gt; in &lt;code&gt;xdiff-interface.c&lt;/code&gt;, and it doesn&amp;rsquo;t scan the whole file. It caps at a constant:&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; style=&#34;color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;&#34;&gt;&lt;code class=&#34;language-c&#34; data-lang=&#34;c&#34;&gt;&lt;span style=&#34;display:flex;&#34;&gt;&lt;span&gt;&lt;span style=&#34;color:#75715e&#34;&gt;#define FIRST_FEW_BYTES 8000
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;So the check is &amp;ldquo;is there a NUL in the first 8000 bytes.&amp;rdquo; A file with clean text for 10KB and a NUL after that is text as far as git is concerned. The cutoff is a performance tradeoff, and it means binary-ness is decided by a sample, not a proof.&lt;/p&gt;
&lt;p&gt;The second problem is bigger.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;plain ascii          NUL present: False  -&amp;gt; text
utf-8 with emoji     NUL present: False  -&amp;gt; text
has a NUL byte       NUL present: True   -&amp;gt; BINARY
utf-16 text          NUL present: True   -&amp;gt; BINARY
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;UTF-16 encodes ASCII characters as the character byte plus a NUL. Any UTF-16 file that&amp;rsquo;s mostly English is roughly half NUL bytes. So git does this to a perfectly valid text file:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ git diff --cached --stat
 lf.txt      |   2 ++
 utf16.txt   | Bin 0 -&amp;gt; 24 bytes
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;Bin&lt;/code&gt;. Git will not diff it, will not merge it, and will not show it in review. The heuristic isn&amp;rsquo;t detecting text, it&amp;rsquo;s detecting C-string-safety, and those aren&amp;rsquo;t the same question.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;file(1)&lt;/code&gt; is more thorough, and it shows how much the BOM is doing:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ file utf16.txt utf16_bom.txt lf.txt
utf16.txt:     data
utf16_bom.txt: Unicode text, UTF-16, little-endian text
lf.txt:        ASCII text
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Identical text content in the first two files. The only difference is two leading bytes. Without them &lt;code&gt;file&lt;/code&gt; gives up and calls it &lt;code&gt;data&lt;/code&gt;; with them it identifies the encoding exactly. For a format with no header, a BOM is the closest thing to one that exists.&lt;/p&gt;
&lt;p&gt;Under the hood &lt;code&gt;file&lt;/code&gt; is doing real work rather than one heuristic. &lt;code&gt;src/encoding.c&lt;/code&gt; carries a 256-entry table classifying every byte value as never-valid-in-text, ASCII, ISO-8859, or extended ASCII, plus a dedicated UTF-8 state machine that rejects invalid sequences. It then tries candidate encodings in order: ASCII, UTF-7, UTF-8 with BOM, UTF-8, UTF-32, UTF-16, Latin-1, extended ASCII, and finally EBCDIC. That ordering is a nice fossil record of which encodings are still worth guessing first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;why-this-matters&#34;&gt;Why This Matters&lt;/h2&gt;
&lt;p&gt;Nearly every format developers work in daily is a convention layered on this substrate. Source code, JSON, YAML, TOML, CSV, Markdown, config files, logs. All of them inherit these problems, and none of them can fully escape them, because the layer underneath has no way to describe itself.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the tradeoff. A format with no header can&amp;rsquo;t tell you anything about itself, which is exactly why it has outlived every format that could. PNG will be readable as long as someone maintains a PNG decoder. A text file is readable as long as someone remembers what bytes are.&lt;/p&gt;
&lt;p&gt;Next in the series: Markdown, which is a text file plus a set of conventions that nobody fully agrees on.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap03.html&#34;&gt;POSIX.1-2017 Base Definitions, Chapter 3&lt;/a&gt; — §3.206 Line, §3.195 Incomplete Line, §3.403 Text File&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc3629&#34;&gt;RFC 3629&lt;/a&gt; — the UTF-8 specification and its byte patterns&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://datatracker.ietf.org/doc/html/rfc2046#section-4.1&#34;&gt;RFC 2046 §4.1&lt;/a&gt; — the &lt;code&gt;text/plain&lt;/code&gt; media type&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.unicode.org/faq/utf_bom.html&#34;&gt;Unicode FAQ on UTF-8, UTF-16, and the BOM&lt;/a&gt; — the Consortium&amp;rsquo;s own guidance on why not to use a UTF-8 BOM&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/git/git/blob/master/xdiff-interface.c&#34;&gt;&lt;code&gt;buffer_is_binary()&lt;/code&gt; in git&amp;rsquo;s &lt;code&gt;xdiff-interface.c&lt;/code&gt;&lt;/a&gt; — the NUL check and the &lt;code&gt;FIRST_FEW_BYTES&lt;/code&gt; cutoff&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/file/file/blob/master/src/encoding.c&#34;&gt;&lt;code&gt;src/encoding.c&lt;/code&gt; in the &lt;code&gt;file&lt;/code&gt; project&lt;/a&gt; — the text-character table and encoding-guessing order behind &lt;code&gt;file(1)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
    </item>
    
  </channel>
</rss>