Core · guide
Text wrapping
Where a paragraph's lines end — the three questions CSS's text-wrap shorthand asks at once, why balance is a search rather than an algorithm, why pretty is only one clause of what the specification licenses, and the one white-space value that changes the string rather than the breaks.
Edit this page on GitHubDocuments
What it is
Wrapping a paragraph is three questions, and CSS's text-wrap shorthand asks all three with one
word. Vixen answers them in three places, and keeping them apart is what makes each one testable:
| Question | Who answers | Type |
|---|---|---|
| May this paragraph wrap at all? | UiDocument.WrapsOf | — |
| Where may a line end? | LineBreaker, by UAX #14 | — |
| What happens to a word too wide to fit? | LineWrapper | <xref:Vixen.Ui.Text.TextWrapMode> |
| Which of the legal breaks does it take? | LineWrapper | <xref:Vixen.Ui.Text.TextWrapStyle> |
⚠ The last two are the pair that get confused, and they cannot answer each other's question.
TextWrapMode is about a word that does not fit at all — a URL in a narrow sidebar — and decides
whether it overflows or is broken inside. TextWrapStyle is about a paragraph every one of whose
breaks is legal, some of which look better than others.
.url { overflow-wrap: anywhere; } /* TextWrapMode — break inside the word */.heading { text-wrap: balance; } /* TextWrapStyle — even lines, same count */.blurb { text-wrap: pretty; } /* TextWrapStyle — no one-word last line */What it is for
A heading that wraps to two lines with one word on the second reads as a mistake, and it is the
commonest typographic defect in an interface built out of boxes that resize. balance is for that:
titles, pull quotes, card headings, anything short and prominent. pretty is for prose — it makes
one promise, that the last line is not a single word, and leaves everything above it alone.
⚠ Neither is free, and that is why auto is the default rather than the good-looking one.
Greedy first-fit is one pass over the paragraph, and a user interface reflows on every resize and
every keystroke. balance costs ten more passes; pretty costs two measurements. Writing either on
a page of body text buys a reader nothing and charges for it on every frame, which is why they are
opt-in and why balancing gives up above a line count.
Using it
From a stylesheet, or with the utility classes:
.title { text-wrap: balance; }.para { text-wrap: pretty; }.code { text-wrap: nowrap; }<label class="text-balance">A heading nobody wants a widow on</label><p class="text-pretty">Body text, and the last line will not be one word.</p>Directly, for a caller doing its own layout:
LineWrapper.Wrap(shaped, maxAdvance: 240f, lines, style: TextWrapStyle.Balance);balance is a search, not an algorithm
⚠ Balancing must never cost a line. Three even lines where two would have done is not balanced,
it is wrong — so what balance looks for is the *narrowest width that still wraps to the line count
the full width gave*, found by bisection over the greedy wrapper. Ten halvings put the answer within
a thousandth of the box, which is inside the narrowest step any real font produces.
A candidate is taken only if it is no wider than the greedy answer as well as no longer. A
narrower trial width can only push words later, and an unbreakable run wider than the trial overflows
it rather than moving — so without that second test a "balanced" paragraph could come out with a line
hanging further out of its box than the one it replaced. With it, balance guarantees two things
that can be measured rather than one that has to be eyeballed.
Minimum raggedness would be the textbook answer and it needs Knuth–Plass to optimise. For a two- or three-line heading, which is what the keyword is for, the narrowest-no-longer width lands on the same breaks — and it is what browsers do.
pretty is one clause, deliberately
CSS Text 4 leaves pretty to the user agent: better hyphenation, fewer rivers, no short last line.
Vixen implements the clause the specification names outright — no last line holding a single
word — and claims nothing else. That clause needs the previous break and no search at all, so the
lines above the last two are untouched.
⚠ It refuses its own cure where the cure would overflow. Pulling a word down lengthens the last line; if the pair does not fit, taking it anyway trades an orphan for a line hanging out of the box, which is worse. It refuses equally where the penultimate line has no earlier break of its own, and where the break between the two lines is one the text required — pulling a word across an authored newline would change what the paragraph says.
white-space asks a fourth question, and one value of it changes the string
text-wrap decides where the lines end. white-space decides that too — nowrap and pre both
answer it — but it asks one more thing first: what the shaped text even is.
⚠ An element with no white-space declaration in Vixen renders as CSS's pre-wrap, not as its
normal. Nothing collapses a run of spaces, and every newline in the string ends a line. That is
worth knowing before writing a stylesheet against this engine, and it is measured rather than
believed — WhiteSpacePreTests asserts both halves.
white-space: pre-line is the one value whose whole content is the part that was missing. It expands
to white-space-collapse: preserve-breaks, which is WhiteSpaceCollapse.PreserveBreaks:
.stanza { white-space: pre-line; } the source string what is shaped "alpha beta" → "alpha beta" a run of spaces and tabs is one space "alpha \n beta" → "alpha\nbeta" a run touching a newline is gone "alpha\nbeta" → "alpha\nbeta" the newline itself is keptIt happens in TransformedText, beside text-transform and for the same reason: before anything is
shaped, so the paragraph is measured and wrapped at the width it will draw at. ⚠ It is the first
thing in this engine that makes the drawn text shorter than what the author wrote, which is why
TransformedText hands out a map in both directions — a caret index, a selection and a line's start
all have to come back as a position in the string the author actually typed.
Phase II is here too. CSS Text § 4.1.3 also removes a collapsible space at the *start or end of a line*. That reads like a question about lines, which do not exist yet when the string is transformed — but after phase I the only runs a line can begin or end on are at the two ends of the text, so for a label that is its own paragraph they are removed with the rest:
" ab" → "ab" drawn from the content edge, as a browser does "ab " → "ab" a right-aligned label ends flush " " → (no line at all) as tall as a label with no textAcross an element boundary too. A display: inline label inside a block container can start in
the middle of a line a sibling began, so its two ends are answered from its neighbours rather than
assumed (#1363). Its leading run goes where nothing comes before it in the container, where a block
or a segment break does, and where the inline content before it ends in a collapsible space — § 4.1.1
removes the second of two collapsible spaces whichever element each is in. Its trailing run goes where
nothing that draws comes after it. Chrome 153 measures every one of these the same as this engine
(Core/Vixen.Ui.Tests/Oracle/inline-collapse.html):
<label>foo </label><label> bar</label> → "foo bar" one space, as in one label <label>foo</label><label> bar</label> → "foo bar" a space on one side is the space <div>x</div><label> ab</label> → "ab" it begins a line after a block⚠ And a label's trailing space is not hung when a word follows it. White space at the end of a
line box hangs — it leaves the measure — and every paragraph here used to hang its last line's,
which is right for a label that owns its lines and wrong for one with a neighbour after it on the
same line: <label>foo </label><label>bar</label> drew foobar, under normal as much as under
pre-line.
⚠ Still owed: a line the wrapper begins or ends between two labels. The first label's trailing space should then hang and the second's leading run, where it did not follow a collapsible space, should go; both are break positions, which a label does not know when it is measured.
A break never starts a line on a space. The only breaks that could fall before one were taken in a
box narrower than a single letter, under line-break: anywhere or overflow-wrap: anywhere, and
there the space used to become a line of its own; it now hangs at the end of the line before, which
is where a browser puts it.
⚠ An empty inline box is looked past, and that is what a browser does. A display: inline
element with no content, with padding or without, or one holding only a float, is laid out as a
single box, and the edge walk steps into it, finds no text and looks past it. This was once listed
as owed. It is not: such a box is an inline box with no content, not an atomic inline, and Chrome
collapses the spaces on either side of it and drops a space before it at the end of a line. An
inline-block is atomic, and it keeps both spaces even at zero width, so what decides is the kind
of box and not its size.
Examples
Four two-letter words in a room of eight, one advance per character — the case
Vixen.Ui.Text.Tests.LineWrapTests works in closed form:
| Style | Lines | Widest |
|---|---|---|
Auto | aa bb cc / dd | 8 |
Balance | aa bb / cc dd | 5 |
Pretty | aa bb / cc dd | 5 |
The same paragraph in a room of four, where the cure will not fit:
| Style | Lines |
|---|---|
Auto | aa bb / cc / dd |
Pretty | aa bb / cc / dd — unchanged, because cc dd is 5 and the room is 4 |
See also
- Inline layout — line boxes, and the strut that decides how tall each one is.
- Text transform — the other property that changes what a line holds.
Core/Vixen.Ui.Text/LineWrapper.cs— the greedy fill and the two second passes over it.Core/Vixen.Ui.Text.Tests/LineWrapTests.cs— the closed-form cases above, and why they use a stub advance array where the rest of that file insists on a real font.docs/plan/43-web-styling-parity.tsv— thetextroot, whose refusal note this feature retired.