EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Web / Thai Names Break Mid-Word Because Browsers Use Dictionaries
Web

Thai Names Break Mid-Word Because Browsers Use Dictionaries

by Ton-Or ·10 Nov 2026·10 min read
Thai Names Break Mid-Word Because Browsers Use Dictionaries

Thai has no spaces between words, so browsers have to guess where they can break a line, and they use dictionaries to do it. A person's name missing from the dictionary is a word they can get wrong every time it lands at the end of a line.

1-minute summary

Symptom / problem
Nicknames missing from the browser dictionary get broken mid-word at line ends
Who should read
Developers of Thai sites whose content comes from a database
Fix in short
Add a middleware that wraps the response and wraps names from the team table in a nowrap span on text nodes only, instead of fixing CSS spot by spot
Jump to steps

A website owner sent me a screenshot of his own nickname in the footer. It was a single-syllable name whose spelling did not match any dictionary word. The last character had dropped onto a new line on its own, leaving the rest at the end of the line above. I kept looking and found the same thing in team cards and article text. The instruction was brief: do not let this happen, for anyone, in any article.

All the names in this article are made up. I will use the nickname ปริ่ม and the full name ปริ่ม วรรณศิริ in place of the real ones. The code structure, numbers, and side effects are from the actual work.

Symptoms: names break mid-word wherever they land at the end of a line

With text such as "ติดต่อทีมงานได้ที่คุณปริ่ม ตลอดงาน" in a narrow box, if the width leaves the character ม just past the edge, the browser breaks the line after ปริ่ and puts ม on a new line. For a Thai reader, it is a word split in half. For the person whose name it is, it is their own name written incorrectly on their own website.

Same box width, nickname at the line endBefore: name splitsติดต่อทีมงานได้ที่คุณปริ่มตลอดงานAfter: name movesติดต่อทีมงานได้ที่คุณปริ่มตลอดงานปริ่ม is not in the dictionaryRead as ปริ่ and ม, allowing a break<span class="nw">ปริ่ม</span>white-space: nowrap prevents splitsBreaks at spaces between first and last names still work; only breaks inside tokens are blocked
On the left is what the website owner saw in the screenshot: the last character of the name dropped onto a new line. On the right, after wrapping the name, the browser moves the whole name down instead.

My first thought was that this was a CSS problem in that component. Adding white-space: nowrap to the name where I saw it should fix it. That only fixed the spot where I added it. The other two places in the screenshot still had the problem. Thinking it through, I realized this approach would never end, because team members' names were not just in templates. They were also in content from the database: around 800 articles, customer reviews, service listing pages, anywhere someone had typed a team member's name into the text. I had no way of knowing in advance which name would land at the end of a line at which screen width.

Cause: browsers use dictionaries to find Thai line breaks

In languages with spaces between words, browsers can break lines at those spaces. Thai runs together through the whole sentence, so browsers have a built-in Thai dictionary and use it to divide text into words to find possible line breaks. Words in the dictionary will not be split in the middle.

The problem is proper names. Many Thai nicknames have individual spellings that do not match dictionary words. The word segmenter tries to split them into pieces it recognizes, or leaves leftover characters as words of their own. The result is a break opportunity in the middle of a name. Normally, nothing looks wrong until that name happens to land at the end of a line.

This differs from Thai data import problems, which I wrote about in Importing Thai Data Without Breaking It. There, the data in the database is wrong. Here, every character is correct; what is wrong is the line layout when the text is displayed. And it happens on the viewer's machine, not on the server.

Options I did not use

Before starting, I went through three options.

Not used

Three options ruled out

  • Add nowrap to individual spots in blade: does not cover database content
  • Insert word joiner characters into names: changes the database contents and leaves hidden characters when searching for or copying names
  • word-break: keep-all: affects line breaking across the whole paragraph
Used

Response middleware

  • One place covers every public page
  • Database contents stay untouched
  • Names come from a table; adding people needs no code changes
  • Cost: one regex pass per page

I chose to handle this at the final stage of every outgoing HTML response, through a single middleware that finds known names in the text and wraps them in <span class="nw">, backed by one line of CSS.

.nw { white-space: nowrap; }

Middleware: touch only text nodes

The risk of modifying HTML with regex is changing the wrong thing. If a name is in an image's alt, in <title>, in a meta description, or in JSON-LD, and I insert a span tag there, the page will break or the data Google reads will contain tags. So I did not run regex over the whole thing; I split the HTML into pieces first.

Final middleware: text nodes onlyresponse HTMLGET + text/htmlpreg_splitTags alternate with textName regexLongest first, cache 1 hrspan.nwwhite-space: nowrapSkipped entirely, left untouchedscript style title textarea option and all tags with their attributesalt, meta description and JSON-LD stay untouched; search result page titles stay the same
preg_split with PREG_SPLIT_DELIM_CAPTURE returns alternating pieces. Even-indexed pieces are text between tags; odd-indexed pieces are tags or skipped blocks. The name regex runs only on even-indexed pieces.
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class KeepNamesTogether
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // เฉพาะหน้า HTML สาธารณะ: ข้ามหลังบ้าน api และหน้า login
        if (! $request->isMethod('GET') || $request->is('admin*', 'api/*', 'login')
            || ! str_contains((string) $response->headers->get('Content-Type'), 'text/html')) {
            return $response;
        }
        $html = $response->getContent();
        if (! is_string($html) || $html === '' || ! ($pattern = $this->pattern())) {
            return $response;
        }
        $response->setContent($this->wrap($html, $pattern));

        return $response;
    }

    private function wrap(string $html, string $pattern): string
    {
        // ตัวคั่น = บล็อกที่ห้ามแตะทั้งก้อน หรือแท็กหนึ่งตัว
        $parts = preg_split(
            '/(<(?:script|style|title|textarea|option)\b.*?<\/(?:script|style|title|textarea|option)>|<[^>]*>)/isu',
            $html, -1, PREG_SPLIT_DELIM_CAPTURE
        );
        if ($parts === false) {
            return $html;                       // regex ล้ม: ส่งของเดิมออกไป ไม่ทำหน้าพัง
        }
        foreach ($parts as $i => $p) {
            // ชิ้นเลขคู่ = text node และทำเฉพาะชิ้นที่มีอักษรไทย
            if ($i % 2 === 0 && $p !== '' && preg_match('/[\x{0E00}-\x{0E7F}]/u', $p)) {
                $parts[$i] = preg_replace($pattern, '<span class="nw">$0</span>', $p) ?? $p;
            }
        }

        return implode('', $parts);
    }
}

My rule was to send the original HTML if anything went wrong. If preg_split returns false, return the original. If preg_replace returns null, use the original piece. If the pattern is empty, skip it. This middleware is cosmetic; it should not have the power to bring down a page.

Checking first whether a piece contains characters in the U+0E00 to U+0E7F range is a shortcut. Pieces containing only whitespace, numbers, or English never spend time in the name regex.

JSON-LD and textarea must be on the skip list

Structured data for search engines lives in <script type="application/ld+json">. If a span slips into it, the JSON still parses, but the author's name will contain HTML tags. With textarea, wrapping the text means people filling out the form will see literal tags in the input field. The split pattern captures both blocks in full, from opening to closing tag, so they end up as odd-indexed pieces and stay untouched.

Names come from a table, not from code

The name pattern is built from the database: every team member's nickname and full name, plus reviewers' names, then cached for one hour.

use App\Models\Review;
use App\Models\TeamMember;
use Illuminate\Support\Facades\Cache;

/** คำที่โผล่ในคอลัมน์ชื่อ แต่ไม่ใช่ชื่อ */
private const STOP = ['คุณ', 'ครอบครัว', 'ท่าน', 'และ', 'แพ็กเกจ', 'งาน', 'รีวิว', 'คณะ',
    'กับ', 'ของ', 'จาก', 'สาขา', 'พี่', 'น้อง', 'บริษัท', 'จำกัด', 'ลูกค้า',
    'หัวหน้าทีม', 'ผู้ประสานงาน', 'ใหม่'];

private function pattern(): ?string
{
    return Cache::remember('nw_pattern_v2', 3600, function () {
        $tokens = [];
        $add = function (?string $text) use (&$tokens) {
            foreach (preg_split('/[\s"\'()\/,]+/u', (string) $text) ?: [] as $t) {
                $t = trim($t, " \t-.");
                if (mb_strlen($t) >= 3 && mb_strlen($t) <= 24
                    && preg_match('/^[\x{0E00}-\x{0E7F}.]+$/u', $t)
                    && ! in_array($t, self::STOP, true)) {
                    $tokens[$t] = true;
                }
            }
        };
        foreach (TeamMember::query()->get(['nickname', 'full_name_th']) as $m) {
            $add($m->nickname);
            $add($m->full_name_th);
        }
        foreach (Review::query()->pluck('name') as $n) {
            // ชื่อผู้รีวิว: รับเฉพาะที่ยาวไม่เกิน 3 คำ กันช่องชื่อที่คนกรอกเป็นประโยค
            if (count(preg_split('/\s+/u', trim((string) $n)) ?: []) <= 3) {
                $add($n);
            }
        }
        if (! $tokens) {
            return '';
        }
        $list = array_keys($tokens);
        usort($list, fn ($a, $b) => mb_strlen($b) <=> mb_strlen($a));   // ยาวไปสั้น

        return '/(?:'.implode('|', array_map(fn ($t) => preg_quote($t, '/'), $list)).')/u';
    }) ?: null;
}

There are several decisions in this function, each with a concrete reason behind it.

  1. Full names are split into tokens at whitespace and punctuation. "ปริ่ม วรรณศิริ" produces two tokens, ปริ่ม and วรรณศิริ. First and last names can therefore still break at the space; only breaks inside tokens are blocked. Wrapping the whole full name as one unit would make narrow boxes overflow instead.
  2. Only entirely Thai tokens between 3 and 24 characters long are accepted. Shorter ones are too likely to match part of an ordinary word. Longer ones are no longer names.
  3. Reviewers' names are accepted only if they are no more than 3 words long, because some review name fields contain sentences, such as which family bought what. Accepting all of them would turn ordinary words into names.
  4. Sort from longest to shortest before joining them into an alternation. Regex chooses the first match. If one person is named ปริ่ม and another ปริ่มฟ้า, and ปริ่ม comes first, it will wrap only ปริ่ม and leave ฟ้า outside. The longer name can still break as before.
  5. Apply preg_quote to every token, because names come from fields people fill in. Stray periods or symbols must not become regex instructions.
  6. The cache key has a version number appended. When I change a rule, such as adding words to the stop list, I bump the version and the old pattern is left behind automatically, without clearing the cache for the whole system.

In practice, this means adding a new team member in the admin interface makes their name take effect across every page within one hour, without changing code or deploying.

Register the middleware at the end of the web group.

// bootstrap/app.php
->withMiddleware(function (Illuminate\Foundation\Configuration\Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\KeepNamesTogether::class,
    ]);
})

Side effect 1: the name in the chat header became smaller

After deployment, I checked 17 desktop pages at widths of 1280, 1440, and 1920, for 51 checks in total, and found something I had not expected. The chat header contained text with a team member's name, and that name was visibly smaller than the surrounding words.

The cause was CSS for the chat header that targeted its descendants broadly.

/* รูปแบบของปัญหา (ชื่อ class กับตัวเลขเป็นตัวอย่าง): ตั้งใจเล็งป้ายสถานะที่เป็น span ในหัวกล่อง */
.chat-head span { font-size: 12px; opacity: .8; }

/* แก้: เล็งด้วย class ของสิ่งที่ตั้งใจจริง */
.chat-head .status { font-size: 12px; opacity: .8; }

When that CSS line was written, the header contained just one span: the status label. A selector like .chat-head span therefore worked correctly. Once the middleware injected a new span around the name, the same rule applied to it too. After the fix, I measured the font size at 15px across the whole line.

The lesson is that injecting elements into HTML for which someone else has already written CSS can collide with selectors that target tag names. I used a short script in the browser console to find cases like this.

// หา span.nw ที่หน้าตาไม่เหมือนข้อความรอบตัว
document.querySelectorAll('span.nw').forEach((el) => {
  const a = getComputedStyle(el);
  const b = getComputedStyle(el.parentElement);
  for (const k of ['fontSize', 'fontWeight', 'color', 'opacity']) {
    if (a[k] !== b[k]) {
      console.log('[DIFF]', k, a[k], '!=', b[k], el.textContent, el.parentElement);
    }
  }
});

Side effect 2: a nickname that was an ordinary word locked up service headings

The second issue was stranger. One team member had an ordinary word as a nickname; suppose it was ใหม่. Once that name entered the pattern, every occurrence of ใหม่ on the website was wrapped in a nowrap span, including service headings containing that word. The service cards used line-clamp to truncate long headings. With a nowrap unit in the middle of a heading, the ellipsis appeared in a different position from the other cards.

The fix was the stop list shown in the code above. Honorifics, common words, and job titles appearing in the name column were excluded from the pattern, along with nicknames that were ordinary words.

Why excluding ordinary-word names loses nothing

The original problem affects names that are not in the browser's dictionary. Nicknames that are ordinary words are already in that dictionary, so the browser does not split them in the first place. Wrapping them does nothing useful and only introduces side effects. The rule for deciding which words belong in the stop list is to ask whether this is a word people commonly use in the language. If it is, it does not need wrapping.

I got this wrong during the design. I thought unnecessary wrapping had no cost, and wrapping first would be safer. But nowrap changes the line layout behavior of everything it encloses. It always has a cost; it is just invisible until it encounters a layout that depends on natural line breaks.

Cost and scope

1 passRegex per page
3600 secPattern cache lifetime
3-24Accepted token length
3 wordsReviewer name limit
51 checks17 pages x 3 widths
15pxChat header after the fix

The cost per request is one preg_split call plus the name regex on text pieces containing Thai characters. The query that retrieves the names is cached, so it runs once an hour. One boundary to understand is that the middleware works only on responses that pass through PHP. If a full-page cache is added in front of the server someday, the cached pages will already have their names wrapped, which works. But new names will take longer than one hour to take effect, depending on that layer's cache lifetime. Content added later by JavaScript will not be wrapped, because it was not in the HTML when it left the server.

In summary, Thai proper names can break mid-word because the browser does not recognize them. The fix that covers every page is to tell the browser, name by name, not to break them, in a single final layer that touches only text, uses names from a table, and leaves words the browser already knows unwrapped. Personally, if a website has proper names in only a few template locations, I would just add a class at those spots; there is no need for middleware. This approach pays off when names are scattered throughout content people have typed. If you use it, adjust the stop list to suit the words on your own website. If I have left gaps in the explanation, I will come back and fill them in later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first