International text
PdfPinata draws Hebrew, Arabic and other right-to-left text in the order it is read, with no extra package. Three separate features are involved, and you can use each one on its own:
| Feature | What it does | What you need |
|---|---|---|
| Reordering | Puts right-to-left text in reading order, using the Unicode Bidirectional Algorithm | Nothing. It is built in. |
| Shaping | Joins Arabic letters, forms Indic conjuncts, and applies kerning and ligatures | The PdfPinata.HarfBuzz package |
| Font fallback | Draws a character with another font when the chosen font has no glyph for it | A list of families to try |
Right-to-left text
DrawString and MeasureString reorder text before they draw or measure it. You pass the string in
the order it is typed, and each run of right-to-left characters turns round inside itself. A
left-to-right word in a right-to-left sentence keeps its own order, and the reverse is also true:
gfx.DrawString("A Hebrew word inside an English sentence:", label, XBrushes.Black, 50, 360);
gfx.DrawString("The word " + hebrew + " means peace.", sample, XBrushes.Black, 50, 387);
gfx.DrawString("An English word inside a Hebrew sentence:", label, XBrushes.Black, 50, 427);
gfx.DrawString(hebrew + " peace " + hebrew, sample, XBrushes.Black, 50, 454);
Reordering is not the same as reversing the string. Hebrew needs nothing more than this. Arabic is drawn in the right order, but each letter keeps its isolated form unless you register a shaper.
Say which way a paragraph runs
The base direction of a line decides which end it starts from. By default
(BidiParagraphDirection.Automatic) it comes from the first strong character, which is any
letter. A Hebrew line that starts with a Latin brand name is therefore laid out left to right. When
you know the direction, say so with XStringFormat.TextDirection:
var declared = new XStringFormat
{
TextDirection = BidiParagraphDirection.RightToLeft,
// A default XStringFormat is not the default DrawString uses. The overload without one
// passes XStringFormats.Default, whose LineAlignment is BaseLine; a new XStringFormat
// leaves LineAlignment at its zero value, Near, which measures from the top of the box
// instead - so the y below would mean two different things on the two lines.
LineAlignment = XLineAlignment.BaseLine
};
// A digit is not a strong character but a Latin letter is, so this line's first strong
// character is the A of ACME and Automatic resolves it left to right. That is the case
// worth showing: with "2026" in front of the Hebrew instead, the first strong character
// is still the Hebrew, Automatic already answers right to left, and declaring it changes
// nothing at all.
var branded = "ACME " + hebrew;
gfx.DrawString("Guessed from the text, then declared right to left:", label, XBrushes.Black, 50, 500);
gfx.DrawString(branded, sample, XBrushes.Black, 50, 527);
gfx.DrawString(branded, sample, XBrushes.Black, 50, 557, declared);
The same property exists in three places, and all three take BidiParagraphDirection from the
PdfPinata.Text namespace:
XStringFormat.TextDirection, for one line drawn withDrawString.XTextFormatter.TextDirection, for a paragraph wrapped into a rectangle.ParagraphFormat.TextDirection, for a PinataLayout paragraph.
XTextFormatter lays out right-to-left paragraphs with every alignment, justified included:
var formatter = new XTextFormatter(gfx)
{
TextDirection = BidiParagraphDirection.RightToLeft,
Alignment = XParagraphAlignment.Justify
};
var column = new XRect(50, 430, 320, 95);
gfx.DrawString("XTextFormatter, declared right to left and justified:",
label, XBrushes.Black, 50, 420);
gfx.DrawRectangle(XPens.LightGray, column);
var paragraph = new StringBuilder();
for (var word = 0; word < 10; word++)
paragraph.Append(word % 2 == 0 ? hebrew : arabic).Append(' ');
formatter.DrawString(paragraph.ToString().Trim(),
new XFont(BundledFontResolver.SansFamily, 13), XBrushes.Black, column);
Shape complex scripts with HarfBuzz
Many scripts need more than one glyph per character. Arabic letters change form depending on their neighbours, Devanagari combines consonants into conjuncts, and Latin text uses kerning and ligatures. This work is called shaping. The rules for it are in the font, and a shaper reads them.
To turn shaping on:
- Add the
PdfPinata.HarfBuzzpackage. - Add the
HarfBuzzSharp.NativeAssetspackage for each platform you deploy to, for exampleHarfBuzzSharp.NativeAssets.Linux. - Register the shaper once, at startup:
using PdfPinata.Fonts;
using PdfPinata.HarfBuzz;
GlobalFontSettings.TextShaper = new HarfBuzzTextShaper();
PdfPinata.HarfBuzz works with either backend, and with no backend at all. The
Installation page lists the packages.
With no shaper registered, each character maps to one glyph through the font's character map. That is right for most Latin, Greek and Cyrillic text, apart from kerning and ligatures.
Shaped text stays searchable and copyable. PdfPinata records which characters each glyph stands for, so a reader can copy a ligature or a joined Arabic word back out as the original characters.
Fall back to another font
If a font has no glyph for a character, PdfPinata draws the font's .notdef glyph, usually an empty
box, and reports no error. Font fallback gives it other families to try:
using PdfPinata.Fonts;
GlobalFontSettings.FontFallback =
new FontFallbackList("Noto Sans Arabic", "Noto Sans Devanagari");
Your font resolver must be able to serve each family in the list. PdfPinata tries them in order
for each character the chosen font cannot draw. It then draws that part of the string in the first
family that can, and goes back to the chosen font after it. One DrawString call can therefore
use several fonts, and each one is embedded:
var asked = new XFont(BundledFontResolver.SansFamily, 20);
gfx.DrawString("A Latin face asked to draw Arabic:", label, XBrushes.Black, 50, 180);
gfx.DrawString(arabic, asked, XBrushes.Black, 50, 210);
gfx.DrawString("Mixed, in one string and one DrawString call:", label, XBrushes.Black, 50, 255);
gfx.DrawString("Peace, " + arabic + ", shalom.", asked, XBrushes.Black, 50, 285);
To choose families by character, implement IFontFallback. Its one method,
FamiliesFor(codePoint, isBold, isItalic), returns the families to try for a Unicode code point. If
your font resolver implements IFontFallback as well, PdfPinata uses it without a separate
registration.
Things to know
- All three features need Unicode-encoded fonts. That is the default for
XFont. Text drawn with a font created withXPdfFontOptions.WinAnsiDefaultis not reordered, shaped or given a fallback, and WinAnsi cannot hold Hebrew or Arabic in any case. PinataLayout'snew PdfDocumentRenderer()uses WinAnsi, so usenew PdfDocumentRenderer(true)for any international text. See Unicode and font embedding. - Shaping changes widths. Kerning and ligatures make text narrower or wider, so lines can break in different places. Register the shaper before you lay anything out, and keep the setting the same between runs if the output must match.
- Set fallback once, before drawing. You can set, replace or clear
TextShaperandFontFallbackat any time. ChangingFontFallbackpart-way through a document changes the fonts used from the next string on. - Fallback does not search your installed fonts. It tries only the families you list, in order.
- Some characters stay with the font around them. Spaces, combining marks, and the zero-width joiner and non-joiner (U+200D and U+200C) never switch font on their own. This keeps shaping intact across word boundaries.
- The zero-width non-joiner works. Put U+200C between two Arabic letters to stop them joining. PdfPinata draws no glyph for it.
- Automatic direction is decided per line in
XTextFormatter. In a right-to-left paragraph, one line that starts with a Latin word or a number is laid out the other way round from the others. SetTextDirectionwhen you know the direction. - Do not dispose the shaper while text is being drawn.
HarfBuzzTextShaperisIDisposable. Register one instance for the life of the application. - Not supported: vertical writing, Arabic justification by stretching letters (kashida), choosing OpenType features such as small capitals, automatic language detection and hyphenation. In a right-to-left PinataLayout paragraph, tab stops stay where a left-to-right paragraph would put them.
See it in action
The International demo draws Hebrew and Arabic in reading order, declares a paragraph direction, joins Arabic letters through HarfBuzz and draws Arabic from a Latin font through fallback. The demo app registers a shaper and a fallback list at startup. Without them, the second and third pages show what you get instead.
The full International demo
var document = new PdfDocument();
document.Info.Title = "International";
// Every character in this file is ASCII, and the right-to-left text is assembled from code
// points instead. A source file that mixes right-to-left text with left-to-right code is
// one no editor renders the way anyone means: the quotation marks appear on the wrong side
// of the string, and a reader cannot see where the text ends and the code begins.
// "shalom", four Hebrew letters. Liberation Sans has Hebrew in it, so this needs neither a
// shaper nor a fallback - the only thing that can be wrong about it is the order.
var hebrew = From(0x05E9, 0x05DC, 0x05D5, 0x05DD);
// "marhaba", a greeting - and a word whose middle letters join on both sides, which is what
// makes it worth drawing twice on the second page.
var arabic = From(0x0645, 0x0631, 0x062D, 0x0628, 0x0627);
// "arabiyya", the name of the script, in it.
var arabicName = From(0x0639, 0x0631, 0x0628, 0x064A, 0x0629);
var heading = new XFont(BundledFontResolver.SansFamily, 16, XFontStyle.Bold);
var label = new XFont(BundledFontResolver.SansFamily, 9, XFontStyle.Bold);
var body = new XFont(BundledFontResolver.SansFamily, 9);
var sample = new XFont(BundledFontResolver.SansFamily, 20);
var arabicFace = new XFont(BundledFontResolver.ArabicFamily, 22);
// ----- page one: the order the letters go on the page -------------------------------------
var first = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(first))
{
gfx.DrawString("Reading order", heading, XBrushes.Black, 50, 60);
Note(gfx, label, body, 50, 90,
"Reordering is not shaping, and needs no shaper.",
"PDF has no notion of direction: a show-text operator paints glyphs at the pen and",
"moves the pen along. So the library reorders before it draws, and a caller who takes",
"no HarfBuzz dependency still gets Hebrew and Arabic the right way round - unjoined,",
"which is wrong, but no longer also backwards, which was the older complaint.");
gfx.DrawString("Hebrew, drawn by the sans face:", label, XBrushes.Black, 50, 165);
gfx.DrawString(hebrew, sample, XBrushes.Black, 50, 192);
gfx.DrawString("The same letters, one call each, left to right:", label, XBrushes.Black, 50, 232);
double at = 50;
foreach (var letter in hebrew)
{
gfx.DrawString(letter.ToString(), sample, XBrushes.Gray, at, 259);
at += 26;
}
Note(gfx, label, body, 50, 295,
"Which is the whole of it.",
"Read those two lines against each other: the word is the row of grey letters in",
"reverse. The first letter written is the rightmost one drawn, and nothing in the",
"calling code said so - the string went to DrawString in the order it is typed.");
// A left-to-right sentence with a right-to-left word in it, and the other way about.
// The whole line is not reversed in either case: each run turns round inside itself,
// which is the difference between the bidirectional algorithm and calling Reverse.
gfx.DrawString("A Hebrew word inside an English sentence:", label, XBrushes.Black, 50, 360);
gfx.DrawString("The word " + hebrew + " means peace.", sample, XBrushes.Black, 50, 387);
gfx.DrawString("An English word inside a Hebrew sentence:", label, XBrushes.Black, 50, 427);
gfx.DrawString(hebrew + " peace " + hebrew, sample, XBrushes.Black, 50, 454);
// Automatic takes the direction from the first strong character, which is right far more
// often than not and wrong exactly where it matters: a right-to-left line opening with a
// brand name, a part number, or a quotation.
var declared = new XStringFormat
{
TextDirection = BidiParagraphDirection.RightToLeft,
// A default XStringFormat is not the default DrawString uses. The overload without one
// passes XStringFormats.Default, whose LineAlignment is BaseLine; a new XStringFormat
// leaves LineAlignment at its zero value, Near, which measures from the top of the box
// instead - so the y below would mean two different things on the two lines.
LineAlignment = XLineAlignment.BaseLine
};
// A digit is not a strong character but a Latin letter is, so this line's first strong
// character is the A of ACME and Automatic resolves it left to right. That is the case
// worth showing: with "2026" in front of the Hebrew instead, the first strong character
// is still the Hebrew, Automatic already answers right to left, and declaring it changes
// nothing at all.
var branded = "ACME " + hebrew;
gfx.DrawString("Guessed from the text, then declared right to left:", label, XBrushes.Black, 50, 500);
gfx.DrawString(branded, sample, XBrushes.Black, 50, 527);
gfx.DrawString(branded, sample, XBrushes.Black, 50, 557, declared);
Note(gfx, label, body, 50, 590,
"The name moves, and the letters do not.",
"The first line opens with a Latin word, so the paragraph is guessed left to right and",
"the name is laid down first, at the left. Declaring it right to left lays the same",
"first word down at the right-hand end instead, where a Hebrew reader begins, and the",
"Hebrew after it - reading leftwards. Neither line reverses its own characters; what",
"changes is which end the line is built from, which is what the paragraph level says.");
}
// ----- page two: the letters themselves ----------------------------------------------------
var second = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(second))
{
gfx.DrawString("Shaping", heading, XBrushes.Black, 50, 60);
// Read rather than assumed, because this demo is worth running both ways: the smoke
// test drives it with no shaper registered, and then the page says so.
var shaping = GlobalFontSettings.TextShaper != null;
Note(gfx, label, body, 50, 90,
shaping ? "A shaper is registered, so these letters join." : "No shaper is registered.",
"An Arabic letter takes a different form according to what it sits between - initial,",
"medial, final or isolated - and which form is which is a rule inside the font, in its",
"GSUB table. Reading that table is what a shaper does. Install PdfPinata.HarfBuzz",
"and set GlobalFontSettings.TextShaper and this page draws joined letters; leave it",
"unset and the same string comes out as isolated forms, correctly ordered.");
gfx.DrawString("The name of the script, in it:", label, XBrushes.Black, 50, 180);
gfx.DrawString(arabicName, arabicFace, XBrushes.Black, 50, 215);
gfx.DrawString("A greeting:", label, XBrushes.Black, 50, 260);
gfx.DrawString(arabic, arabicFace, XBrushes.Black, 50, 295);
// The same characters with a zero-width non-joiner between each pair, which asks the
// face for the isolated forms. It is the comparison that makes the line above legible:
// two rows of the same letters, differing only in shape.
var unjoined = string.Join(From(0x200C), arabic.ToCharArray());
gfx.DrawString("The same five letters, asked not to join:", label, XBrushes.Black, 50, 340);
gfx.DrawString(unjoined, arabicFace, XBrushes.Gray, 50, 375);
Note(gfx, label, body, 50, 410,
"U+200C, the zero-width non-joiner.",
"It is bidirectional class BN, so rule X9 removes it before the algorithm resolves",
"anything - and it still has to reach the shaper, because it is the character that",
"says how the letters on either side of it join. So it is inside the run and not on",
"the page: no glyph is drawn for it, and the two rows differ only in shape.");
gfx.DrawString("What the pieces are:", label, XBrushes.Black, 50, 495);
Note(gfx, label, body, 50, 512, null,
"TextItemizer cuts a string into runs of one direction and one script. ITextShaper is",
"handed one run at a time, with its script and its language. ShapedGlyph.Cluster maps",
"each glyph back to the characters it stands for, which is what writes /ToUnicode - so",
"the text can still be selected and copied out of the finished page, even where one",
"glyph swallowed two characters.");
}
// ----- page three: a face that has the character ------------------------------------------
var third = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(third))
{
gfx.DrawString("Fallback", heading, XBrushes.Black, 50, 60);
var fallback = GlobalFontSettings.FontFallback != null;
Note(gfx, label, body, 50, 90,
fallback ? "A fallback is registered." : "No fallback is registered.",
"Every line on this page asks for the sans face, which has not one Arabic glyph.",
"Without a fallback each Arabic character is .notdef - an empty box - with no warning",
"at any layer, even though a face that could draw it is loaded in the same process.",
"GlobalFontSettings.FontFallback says which families to try instead.");
// Note the font on every line below: the sans. Nothing here names the Arabic family.
var asked = new XFont(BundledFontResolver.SansFamily, 20);
gfx.DrawString("A Latin face asked to draw Arabic:", label, XBrushes.Black, 50, 180);
gfx.DrawString(arabic, asked, XBrushes.Black, 50, 210);
gfx.DrawString("Mixed, in one string and one DrawString call:", label, XBrushes.Black, 50, 255);
gfx.DrawString("Peace, " + arabic + ", shalom.", asked, XBrushes.Black, 50, 285);
Note(gfx, label, body, 50, 320,
"Two faces on one line, and both embedded in the file.",
"The run is cut where coverage changes, each piece is drawn by the face that has the",
"character, and the face the caller asked for is selected again at the end. A space is",
"never given a face of its own: it carries no shape worth choosing one for, and",
"claiming it would split a sentence into one run per word and lose the shaping across",
"every one of the boundaries.");
// XTextFormatter lays a paragraph into a rectangle and places each line itself, so it
// has to be told which way the text runs for the same reason a page does.
var formatter = new XTextFormatter(gfx)
{
TextDirection = BidiParagraphDirection.RightToLeft,
Alignment = XParagraphAlignment.Justify
};
var column = new XRect(50, 430, 320, 95);
gfx.DrawString("XTextFormatter, declared right to left and justified:",
label, XBrushes.Black, 50, 420);
gfx.DrawRectangle(XPens.LightGray, column);
var paragraph = new StringBuilder();
for (var word = 0; word < 10; word++)
paragraph.Append(word % 2 == 0 ? hebrew : arabic).Append(' ');
formatter.DrawString(paragraph.ToString().Trim(),
new XFont(BundledFontResolver.SansFamily, 13), XBrushes.Black, column);
Note(gfx, label, body, 50, 550,
"Justifying is the alignment that needed changing.",
"Every other one hands a whole line to DrawString, which orders it. Justifying places",
"each word itself, so it has to ask where each belongs - and order them by the",
"leftmost position any of their characters ends up at rather than by the first,",
"because a right-to-left word's first character is its rightmost one.");
}
return document;