Laying out rich, bidirectional text

Between versions 0.2.1.0 and 1.3.0.0, Balkón was majorly upgraded to support a new range of variation within a paragraph. Text within the same paragraph can now be laid out in a combination of two directions (left-to-right and right-to-left), different fonts with different line heights can be mixed and matched, and parts of the text can be wrapped in inline boxes with added spacing, such as that required for drawing inline borders.

Paragraph alignment and proper right-to-left support

Balkón uses a coordinate system where x coordinates grow from left to right. This is consistent with typical coordinate systems for graphical interfaces, including the coordinate system used by web technologies, in which convention additionally places the default origin in the top-left corner of a given context.

This creates a bias: left-aligned left-to-right text can be laid out trivially (including overflow), by starting at x=0 and then stacking glyphs in the positive x direction, whereas other text directions and alignments require more complex handling.

Right-to-left text in this sort of coordinate system cannot start at x=0, because then it would immediately start overflowing the left edge of the given context. Instead, it needs to start at a positive x coordinate corresponding to the right edge of the context, then advance in the negative direction, towards zero. Unlike its left-to-right counterpart, right-to-left text may overflow the left edge and require negative x coordinates, or in situations when it is neither overflowing nor left-aligned, it may not even reach x=0. This makes measuring line width more difficult. Balkón version 1.2.0.0 exposes a new function named paragraphSafeWidth to help calculating the max-content width for CSS reliably.

From version 1.2.0.0, Balkón also supports centred alignment, which positions non-overflowing text exactly halfway between what would be its left-aligned position and its right-aligned position.

Bidirectionality

Previous versions of Balkón were already able to process left-to-right text as well as right-to-left text, but not both within the same paragraph.

This does not suffice for the global web, where bidirectional text is common. Such text may appear when mixing languages (e.g. a right-to-left Hebrew name embedded in a left-to-right English sentence), but also within some monolingual text (e.g. left-to-right numbers within right-to-left Arabic text).

Therefore, Balkón needs to support bidirectional text, and the standard way to achieve this is using the Unicode Bidirectional Algorithm (BiDi), specified in the Unicode Standard Annex #9. The World Wide Web Consortium (W3C) has published a great introductory article, which also succinctly explains the basic concepts of bidirectionality.

Starting with Balkón version 1.0.0.0, a rich text interface is available, which requires setting the base direction of the input paragraph (left-to-right or right-to-left). This ensures that runs of alternating text direction are arranged in the expected overall direction.

To further influence the direction of text, Unicode provides several invisible characters to define directional embeddings, overrides, isolates, and marks. Of special interest is the distinction between embeddings and isolates: directional embeddings are an older mechanism whose correct use is often counter-intuitive and requires some insight into the BiDi algorithm, whereas directional isolates have been available since Unicode 6.3.0 and are the currently preferred, more intuitive mechanism.

Higher-level protocols, such as the dir attribute and the <bdi> element in HTML, may also influence text direction. Unicode requires these protocols to have the same effect as if the corresponding formatting characters were used instead. This conversion is not implemented in Balkón and has to be handled on a higher level, for example in CatTrap.

The current version of the BiDi algorithm is rather complex and would take a lot of effort to reimplement from scratch. A more straightforward option for a text layout engine is to include the BiDi implementation from the International Components for Unicode (ICU) library, available for C/C++ and Java.

Haskell bindings to the ICU library were already used by Balkón for querying character metadata and for finding line break boundaries. Unfortunately, their BiDi bindings are incomplete and the provided high-level interface is not suitable for rich text. In the interim, Balkón implements a simplified bidirectional algorithm, which does not comply with the Unicode standard (for example, it does not properly handle parentheses or number separators), but it supports basic use cases and is intended to be replaced by ICU bindings once these become available.

The CSS setting unicode-bidi: plaintext allows the paragraph to change base directions between lines, which could also mean that each line can be aligned differently. This is currently not supported by Balkón.

Font dimensions

As I mentioned in the first blog post, browsers use a font’s ascent and descent metrics to determine the vertical size of inline text. These values gain even more importance when handling rich text. But since they are not available in HTML or CSS, this can make web design more difficult.

CSS lets designers control the size of a font’s EM square via the font-size property, and the height that the text contributes to its line via the line-height property. What may not be so obvious is that the EM square is merely a design convention, not an actual size of the font’s glyphs. (Sadly, even articles linked from MDN get this wrong.) Furthermore, browsers keep track of a text’s baseline, and how much the text extends above and below the baseline is controlled by the ascent and descent metrics. Without knowing these metrics, or without knowing the exact font that will be used, a web designer cannot know where exactly the baseline will be. If the font specified by font-family is unavailable or overridden by the user, inline positioning can no longer be done in a precise manner.

If an inline box only contains other boxes and no text, it may be useful to set font-size: 0 and line-height: 0 on the outer box. This makes the position of the baseline predictable, because all font metrics will be effectively multiplied by zero.

Balkón version 1.3.0.0 adds options to override a font’s internal ascent and descent metrics. This is not a CSS feature, but it may be useful when Balkón is used outside of a web browser, or when it has to handle non-textual content.

Inline boxes

The original “plain text” interface only supported structuring the input text into a flat list of “spans”. In theory, such flat list could also be used to allow rich text formatting. This could work for CSS properties that are inherited, such as font-weight or line-height, where inheritance could be handled by the caller.

However, CSS formatting is more complex than that. For instance, it allows inline elements to define their own margins, borders, and/or padding. These are not inherited, but they “stack”, meaning that one text sequence nested in 3 levels of inline boxes might have 3 layers of borders around it, which can affect the space available on a line.

This means that for Balkón to be usable as a text layout engine for a web browser, a flat list would not be sufficient. The input would somehow need to contain information about box hierarchy.

To address this, the “rich” interface available from Balkón version 1.0.0.0 allows structuring input text into a tree of inline boxes and text sequences. This tree is then flattened by Balkón, but references to each text sequence’s ancestry are kept around, so that nested boxes are correctly taken into account.

If the input contains empty boxes that should be rendered, Balkón requires them to contain a text sequence of zero length, which will be used to represent the empty box in the flattened list of fragments in the output.

Boxes by themselves do not isolate directional runs, so if text direction changes in the middle of a box, the box may be broken up into multiple discontinuous fragments, even within the same line. This makes it difficult to preserve the original tree structure internally, so Balkón’s rich text interface outputs a flat list of fragments ordered visually from left to right and from top to bottom. A higher level layout engine such as CatTrap may attach arbitrary data to the input boxes and then use that to map the output fragments back to the original tree.

Since Balkón does not actually draw backgrounds or borders, it does not need to distinguish margins from borders or padding. These are combined together into one metric called “spacing”. Furthermore, only the inline-axis spacing actually affects layout, so Balkón does not need to care about block-axis spacing. For horizontal text, this means that spacing can be defined as only two values, one for the left edge and one for the right edge.

When a box is broken over multiple lines, spacing is only applied once on the first line and once on the last line. Which of these lines gets the left spacing and which gets the right spacing, depends on the box’s text direction.

Box padding should extend from the content area of an inline box; however, CSS specification fails to define what that area is. Chromium and Firefox seem to use the font’s ascender and descender as the vertical size of the content area, which is mentioned as an example in CSS Inline Layout but not actually standardised. This behaviour may also be confusing to web designers, who might expect the content area to be independent of font metrics and to be based on the value of line-height instead. Balkón version 1.2.0.0 exposes additional text metrics to allow implementing this popular behaviour in Haphaestus, while not requiring it.

Another CSS peculiarity is that lines may become invisible under certain conditions. Among other things, these conditions include lines with “no inline boxes with non-zero margins, padding, or borders”. This allows a hack: adding an element with margins and paddings that cancel each other out, for example margin: -1px; padding: 1px, will force an otherwise empty line to become visible.

Font, line height, and vertical alignment

Some rich text features could already be achieved outside of Balkón, even when using the “plain text” interface. Balkón simply outputs glyph identification and position. It does not care about text properties that do not affect layout, like colour. A caller could mark parts of the paragraph to be rendered in a different colour, underlined, etc.

However, there are other rich text features which do affect layout, so an upgraded support from Balkón was needed.

The simplest upgrade was to allow combining different font objects in the same paragraph. A font object (as used by HarfBuzz) is defined by its font face, but also its size, weight, and style. This allows formatting parts of the paragraph in boldface or in italics, simply by swapping the font object.

A more complex situation develops when line height needs to vary as well. Different font sizes, but sometimes even different fonts of the same size, may require a different amount of vertical space to be readable.

Balkón allows control of this vertical space in the same way as the CSS line-height property does. But mixing line heights in this way means that text fragments of different heights need to be aligned somehow. This is achieved using the CSS vertical-align property. Balkón version 1.3.0.0 supports the line-relative values top and bottom, as well as baseline alignment with an optional offset. (Baseline alignment is another feature that requires knowing about ancestor boxes, and would therefore not work correctly with a flat list.)

Experimentation has shown that vertical alignment does not work consistently across modern browsers. Some behaviour remains undefined even in the current working draft of the CSS Inline Layout Module Level 3, for example “what to do for top/bottom/center aligned boxes that are taller than the rest of the content”. Balkón mimics Firefox, which appears to first use bottom-aligned boxes to stretch the line upwards as necessary, then use top-aligned boxes to stretch it downwards as necessary. In contrast, Chromium seems to give priority to boxes with line-relative alignment that appear earlier in the parent box. Another ambiguous case can be triggered by nesting a box with vertical-align: 10px inside a box with vertical-align: top. Reasoning about interactions of these CSS properties becomes circular, so browsers have to pick a point where this cycle is broken, and each picks a different one.

CSS also requires that inline boxes always behave as if they contain a “strut” (an invisible glyph of zero width) when they would otherwise contain no glyphs. This “strut” can stretch an inline box, and therefore also the whole line, beyond what would be required to simply contain all its glyphs. Explicit line dimensions had to be added to the output of Balkón to avoid trimming the extra space from the edges of the paragraph. The existence of struts is why font setting matters even on boxes that do not contain any text directly.

Status of the project

While still experimental, lacking proper ICU bindings, and with some features yet to be implemented, Balkón has grown to include a feature set that should make it decently usable in an uncomplicated web browser. Many missing CSS features can now be added without large changes to the API.

This milestone marks my departure from the project, leaving Adrian to integrate the latest features of Balkón with CatTrap and Haphaestus. My hope is that Balkón in its current state provides good support to the Argonaut stack, and that it perhaps receives more funding in the future, so that developers – whether that means me or someone else – can improve it with bug fixes, cleanups, optimisations, and new features when needed.