PDFPipe

Southeast Asian scripts / Thai

Thai text in a PDF

Thai is written without spaces between words and stacks marks up to three levels above the baseline, so both line breaking and line height need deciding rather than defaulting.

A font with the characters is not enough

Thai does not put spaces between words. It puts them between phrases and sentences, so a renderer with no Thai dictionary either breaks nowhere, producing one enormous unbreakable word that overflows the page, or breaks anywhere, producing line breaks in the middle of words. Separately, Thai marks stack: a consonant can carry an upper vowel and a tone mark above it and a lower vowel below it. That is three levels of mark on one line of text, and a line-height set for Latin makes the tone marks of one line collide with the descenders of the line above.

What the failure looks like

Either a single line of Thai running off the right edge of the page with nothing wrapping, or wrapping that a Thai reader will tell you is in the wrong place. The mark collision is subtler: it looks like tight leading until you notice a tone mark sitting on top of a letter from the line above.

Line breaking

This is the whole problem. Correct Thai line breaking needs a dictionary, because word boundaries are not marked. Where the renderer has one it is used automatically; where it does not, the practical options are to insert zero-width spaces at word boundaries during data preparation, or to accept phrase-level breaking at the spaces that do exist.

The markup and the CSS

The language attribute is doing real typographic work in every one of these examples. It is what lets the engine choose a regional glyph form, a case mapping or a hyphenation dictionary, and leaving it off is the most common single omission in a multilingual document.

html
<p lang="th" class="thai">ใบแจ้งหนี้ของคุณพร้อมแล้ว</p>

<style>
@font-face {
  font-family: "Noto Sans Thai";
  src: url("https://assets.example.com/fonts/noto-sans-thai.woff2")
       format("woff2");
}

.thai {
  font-family: "Noto Sans Thai", sans-serif;
  /* Three levels of mark above the baseline plus one below. 1.6 is a
     floor rather than a preference, and 1.8 is safer for dense text. */
  line-height: 1.8;
  /* Ask for the dictionary-driven breaking where the engine has one,
     and fall back to breaking rather than overflowing where it does not. */
  line-break: normal;
  word-break: normal;
  overflow-wrap: break-word;
}</style>

The mistake people make

Setting line-height in a unitless value tuned for Latin, usually 1.4 or 1.5, and never looking at a Thai document. The stack is taller than Latin ascenders and descenders, so the marks are the first thing to collide and the last thing an English-speaking reviewer notices. Set it explicitly for the script rather than inheriting.

How to test this without reading the language

Render a string you know is correct, and compare it against a reference rendering from a system that definitely handles the script, such as a browser on a machine with the language installed. Comparing against nothing tells you only that characters appeared, and for most of the scripts on this site characters appearing is not the same as text being right. Where you can, get one native reader to look at one page once; it is faster than any amount of specification reading.

Frequently asked

Why does Thai look wrong when every character is there?

Either a single line of Thai running off the right edge of the page with nothing wrapping, or wrapping that a Thai reader will tell you is in the wrong place. The mark collision is subtler: it looks like tight leading until you notice a tone mark sitting on top of a letter from the line above. The underlying reason is that a font is two things: a set of glyphs and a set of layout tables that say how those glyphs combine and position. Character coverage is the first half. Most of the failures on this page are the second half.

Does the lang attribute actually change the rendering?

Yes, and it is not an accessibility nicety here. It selects regional glyph forms where a code point has more than one correct shape, it picks the case mapping for a text transform, it chooses a hyphenation dictionary, and it triggers the locl OpenType feature. A document with no lang attribute gets whatever the font defaults to, which is usually right for one language and wrong for the others.

Can I subset the font to keep the file small?

Carefully, and not with a tool configured for Latin. Subsetters routinely drop the GSUB and GPOS tables that shape and position glyphs, because for Latin those are largely optional. The subset renders without error and every complex feature in it is broken. Test a subset by rendering real text and looking at it, never by checking that the file got smaller.

Related writing systems

Scripts with a related mechanism, and one from each of the other families.

Paste a real string in this script into the playground and compare the output against a reference. It is the only test that means anything if you do not read the language.