At the end of the previous article, I mentioned that I had built a library that wraps text at semantic boundaries. This time, I want to write about how to use it, what happens inside, and why I built it that way. Screenshots and links first .. (stars appreciated)
At first, I just wanted line breaks like this.
- 좋은 제품을 만들기 위해 작은
- 불편부터 하나씩 개선합니다
+ 좋은 제품을 만들기 위해
+ 작은 불편부터 하나씩 개선합니다
Korean already uses spaces to separate word units, so word-break: keep-all can prevent breaks in the middle of a word, and text-wrap: balance puts the breaks at, quite literally, balanced positions.
The one thing I was missing was that text-wrap: balance only considers visual balance, without considering what feels more natural in meaning. For example, visually, 좋은 제품을 만들기 위해 작은 / 불편부터 하나씩 개선합니다 might look better. But we read sentences in meaningful chunks, so 좋은 제품을 만들기 위해 / 작은 불편부터 하나씩 개선합니다 feels more natural: “To build a good product / we improve small inconveniences one by one,” keeping “small” with “inconveniences.”
I didn't want to go through and insert every line break by hand, though. (Well, an LLM could probably do a pretty good job of that ..) And something that looks fine on a wide screen could look strange on a narrow one, or become awkward again after a font or font-size change. So I thought it would be nice to find natural semantic boundaries at runtime and wrap text to fit the actual available width. That's why I made semantic-wrap.
Before we start: the code snippets in this article may omit parts of the actual code or differ between versions, so please check the repository for the exact code ..
Using it in React is simple. Import the Korean model and wrap the element whose text you want to wrap. This is also how I've applied it to the titles on this blog.
import { koTitleModel } from "@semantic-wrap/ko";
import { SemanticWrap } from "@semantic-wrap/react";
<SemanticWrap model={koTitleModel}>
<h1>좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다</h1>
</SemanticWrap>
SemanticWrap uses the child element directly, without adding a separate wrapper. It measures the font and width actually being displayed, chooses the break positions, and inserts <br> at those positions if it decides to use the calculated result. The child therefore needs to be a single text element that can forward a ref to an actual HTML element.
Oh, and wrapping an element doesn't mean its line breaks will always change. It only changes them when it judges the new breaks to be more meaningful than the browser's existing breaks. More on that later ..
The packages are split into core, react, and ko/en. core contains the logic that works with just text and widths. It can therefore run outside React or the DOM: it calculates layouts that fit the available width using the boundaries suggested by the model, then chooses the final result. react contains the wrapper that makes this core logic easy to use in React, and ko/en contain trained example models.
Core calculates the optimal result from its inputs. Give it the text, a model, the available width, and a function that measures a string's width. The model predicts boundaries, Core checks whether the lines created by breaking at those boundaries fit within the given width, and it weighs meaning against balance across those combinations to choose the final breaks.
These calculations aren't tied to a particular environment, so the core logic can run anywhere that can supply inputs matching the interface, even outside a web browser. It simply returns the result the library considers optimal; it doesn't modify the DOM itself.
Here's selectLineBreaks, the function that runs this whole calculation in one go.
declare function selectLineBreaks(
input: SelectLineBreaksInput,
options?: SelectLineBreaksOptions,
): LineBreakSelection;
interface SelectLineBreaksInput {
text: string; // Original text
model: PhraseModel; // Boundary model
maxWidth: number; // Maximum width
measureText(text: string): number; // Return the string width in the same unit as maxWidth
}
The name selectLineBreaks might make it sound like there's one giant line-breaking algorithm inside this function, but it's really closer to a convenience wrapper that runs the entire core process. The actual calculation happens through a plan created by createLineBreakPlan, which produces its result in the order aggregate → calculate → select.
Let's follow that in code. To make the results easy to compare by eye, I'll assume that every character and space has a width of 1.
import { createLineBreakPlan } from "@semantic-wrap/core";
import { koTitleModel } from "@semantic-wrap/ko";
const plan = createLineBreakPlan({
text: "좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다",
model: koTitleModel,
});
const measurement = {
maxWidth: 20, // Available width per line; 20 characters here
measureText: (text: string) => text.length, // Illustrative measurement, not actual pixels
};
First, the model tells us where it might be good to break. A model can have one level or several. The default model uses three levels, coarse, medium, and fine, and more than one level can choose the same position. In this sentence, both medium and fine choose offset 6, the space after 제품을, as shown below. medium has a penalty of 0.35 and fine has 0.7. Which value should we use when actually breaking there?
That's what aggregate decides. The default aggregator, lowestPenalty(), keeps the lowest penalty among the predictions for the same position. There are also spaces where a break is possible but no model chose the position. Those remain available with the model's fallbackPenalty of 1.
const candidates = plan.aggregate();
console.log(candidates);
// [
// { offset: 2, level: null, penalty: 1 },
// { offset: 6, level: 1, name: "medium", penalty: 0.35 },
// { offset: 10, level: null, penalty: 1 },
// { offset: 13, level: 0, name: "coarse", penalty: 0 },
// { offset: 16, level: null, penalty: 1 },
// { offset: 21, level: null, penalty: 1 },
// { offset: 25, level: null, penalty: 1 },
// ]
offset is where the preceding line ends in the original text, and level is the index of the model level that determined the cost at that position. null means it's a fallback rather than a position chosen by a model. Offset 13, before 작은, was chosen by the coarse model and costs 0; offset 16, chosen by no model, costs 1.
What aggregate determines is where the sentence can break and which boundaries we prefer.
Now we need to combine these boundaries into lines that actually fit. If we break before 작은, will the rest fit on one line? If we include 작은 on the first line, how similar will the two lengths be? This is where we need the actual container width and text widths.
const layouts = plan.calculate(measurement);
console.log(layouts.map(({ breaks, widths, modelCost, balanceScore }) => ({
breaks, // Break positions in the original text
widths, // Measured width of each line
modelCost, // Sum of the selected boundaries' penalties
balanceScore, // Imbalance between line lengths; lower means more balanced
})));
// [
// { breaks: [16], widths: [16, 14], modelCost: 1, balanceScore: 0.05 },
// { breaks: [13], widths: [13, 17], modelCost: 0, balanceScore: 0.1 },
// ]
The first is 좋은 제품을 만들기 위해 작은 / 불편부터 하나씩 개선합니다, and the second is 좋은 제품을 만들기 위해 / 작은 불편부터 하나씩 개선합니다. Both have two lines, each fitting within the width of 20. But the first has more similar lengths, while the second breaks at a position the model prefers. So which one should we keep?
For now, both. The default calculator, optimalLayouts(), finds the minimum number of lines that fit, then keeps the candidates that can't be discarded by considering only semantic cost or only balance.
Finally, select picks one of the remaining candidates. The default selector, balance(), allows candidates whose scores differ from the most balanced result by up to tolerance, then favors the lower model cost among them. The default tolerance is 0.12. The two candidates above differ in balance score by 0.05, so it can choose offset 13, with its model cost of 0.
const selection = plan.select({
...measurement, // Width and measurement function for this selection
diagnostics: true, // Use the overload that also returns intermediate results
});
console.log(selection.lines);
// ["좋은 제품을 만들기 위해", "작은 불편부터 하나씩 개선합니다"]
console.log(selection.breaks); // [13]
console.log(selection.applied, selection.reason); // true "calculated-selected"
console.log(selection.diagnostics.selection);
// { selected: "calculated", index: 1, reason: "calculated-selected" }
Here, index: 1 means the second layout in the calculated candidate list was chosen. The select function tells us which candidate it picked, and the plan returns that layout's line strings and boundary positions as the final result. applied doesn't mean the screen has already been modified either; it means the calculated layout meets the conditions for applying it. Actually drawing it is still up to the caller.
But what if the browser has already wrapped the text well? Pass nativeLayout too if you want to compare against that result.
const withNative = plan.select({
...measurement,
// Inject [16] here to illustrate the comparison.
// In actual use, pass the default break positions measured on screen.
nativeLayout: { breaks: [16] },
});
console.log(withNative.breaks); // [13]
console.log(withNative.applied, withNative.reason); // true "calculated-selected"
The native layout breaks at [16], so its model cost is 1. The calculated [13] layout still has two lines, costs 0, and falls within the allowed balance range, so it decides to change it. Conversely, if no candidate improves on the native layout, it may keep the original breaks.
In the end, selectLineBreaks is just this. It lets you supply the text, model, and width together and get a result, for convenience.
function selectLineBreaks(
input: SelectLineBreaksInput,
options: SelectLineBreaksOptions = {},
) {
const plan = createLineBreakPlan({
text: input.text,
model: input.model,
strategy: options.strategy,
});
const measurement = {
maxWidth: input.maxWidth,
measureText: input.measureText,
measureTexts: input.measureTexts,
nativeLayout: options.nativeLayout,
};
return options.diagnostics
? plan.select({ ...measurement, diagnostics: true })
: plan.select(measurement);
}
And as I mentioned earlier, the calculation doesn't have to use the DOM. For example, you can measure string widths with canvas like this.
const context = document.createElement("canvas").getContext("2d")!;
context.font = "700 28px system-ui";
const input = {
text: "좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다",
model: koTitleModel,
maxWidth: 360,
measureText: (text: string) => context.measureText(text).width,
};
const result = selectLineBreaks(input, {
diagnostics: true, // Get intermediate calculations alongside the final result
});
console.log(result.lines);
// ["좋은 제품을 만들기 위해", "작은 불편부터 하나씩 개선합니다"]
console.log(result.breaks);
// [13]
console.log(result.applied, result.reason);
// true "calculated-selected"
console.log(result.diagnostics.calculatedLayouts);
// [
// {
// breaks: [16],
// lines: ["좋은 제품을 만들기 위해 작은", "불편부터 하나씩 개선합니다"],
// widths: [314.32, 302.48],
// selectedCandidates: [{ offset: 16, level: null, penalty: 1 }],
// lineCount: 2,
// balanceScore: 0.0164,
// modelCost: 1,
// overflow: false,
// },
// {
// breaks: [13],
// lines: ["좋은 제품을 만들기 위해", "작은 불편부터 하나씩 개선합니다"],
// widths: [259.96, 356.84],
// selectedCandidates: [{ offset: 13, level: 0, name: "coarse", penalty: 0 }],
// lineCount: 2,
// balanceScore: 0.1346,
// modelCost: 0,
// overflow: false,
// },
// ]
To briefly explain the result .. looking only at line lengths, including 작은 on the first line is much more balanced. But the boundary before 작은 was chosen by the coarse model, so its penalty is 0. The difference between the two balance scores also falls within the default tolerance of 0.12, so offset 13 is selected. In other words, it chooses the semantically preferred second candidate over the most visually balanced first candidate, since this much compromise on balance is acceptable.
Oh, and since this example doesn't pass nativeLayout, it only compares the calculated candidates with one another. To compare against other breaks too (probably the web browser's default breaks in most cases), you need to pass the actual measured boundary positions as nativeLayout: { breaks: [...] } in the second argument. The React package, which I'll describe next, handles that measurement for you as well.
How do we get the widths to pass to Core in a browser, then? Even the same sentence has different widths depending on the font, font size, and letter spacing. To compare against the default wrapping, we also need to know where the browser actually broke the lines.
This doesn't mean constantly changing the visible title to measure it. Candidate string widths are measured with a hidden span, while default wrapping is checked with an offscreen element that has the same tag, computed style, and container width. Using Range to find where characters' vertical positions change tells us which line they belong to. This lets us read the result rendered in the actual font, but it doesn't reproduce every part of the original element's context, so things like changes to ancestor styles still need more consideration.
SemanticWrap, which I showed at the beginning, handles this part. It measures the element and passes the measurements to Core, inserts <br> at the chosen positions when the calculated layout is selected, and shows the original text when the native wrapping is selected. That means the caller can simply wrap a title, without writing a measurement function or turning the calculation results into JSX each time.
import { koTitleModel } from "@semantic-wrap/ko";
import { SemanticWrap } from "@semantic-wrap/react";
<SemanticWrap model={koTitleModel}>
<h1>좋은 제품을 만들기 위해 작은 불편부터 하나씩 개선합니다</h1>
</SemanticWrap>
If you want to render the calculation results yourself, you can also use the useSemanticWrap hook. Pass the original text to measure as text, and attach the returned ref to the actual heading element. For example, like this.
import { Fragment } from "react";
import { koTitleModel } from "@semantic-wrap/ko";
import { useSemanticWrap } from "@semantic-wrap/react";
export function Title({ text }: { text: string }) {
const { ref, selection } = useSemanticWrap({
text,
model: koTitleModel,
});
return (
<h1 ref={ref}>
{selection?.applied
? selection.lines.map((line, index) => (
<Fragment key={index}>
{index > 0 && <br />}
{line}
</Fragment>
))
: text}
</h1>
);
}
selection may be null before measurement or while recalculating after a text or width change, so we show the original text then. If it decides to keep the native wrapping, applied is false, so we use the original text in that case too. The hook doesn't directly change children or CSS, so the caller also decides whether to hide the title until the calculation finishes.
Finally, how did I implement the models that tell us the semantic boundaries? Honestly, I didn't do anything particularly special; I just trained BudouX and used it. I added a little hierarchy to the design to make better use of those models.
The example models koTitleModel and enTitleModel, provided by @semantic-wrap/ko and @semantic-wrap/en, both use weights in BudouX format and offer boundary predictions at three levels: coarse, medium, and fine. Think of coarse as the preferred places to break, medium as the next acceptable ones, and fine as places we can split if space gets tight. So coarse represents stronger semantic boundaries, with medium and fine representing progressively weaker ones.
Why three levels? Even the earlier 좋은 제품을 만들기 위해 / 작은 불편부터 하나씩 개선합니다 may need another break in the second half on a narrower screen. We can't say never to break inside 작은 불편부터 하나씩 개선합니다, and treating every space equally takes us right back to the original problem. So instead of only deciding whether a break is possible, I made the model express preferences and left the actual choice to the available width.
I got a hint from Typesetting for Improved Readability using Lexical and Syntactic Information, which I came across while looking into this. The paper explores choosing line breaks using lexical and syntactic information, and suggests grading the unnaturalness of a break into three or four levels instead of using a binary break/no-break distinction. That would let us adjust the tradeoff between avoiding unnatural breaks and balancing line lengths. (The paper mentions this as a direction for future work; it doesn't mean they implemented and validated that graded approach.)
The current presets assign penalties of 0, 0.35, and 0.7 to the three levels, leaving spaces that no level selected at 1. This is where the costs Core compared earlier come from. Spaces the model doesn't prefer remain available when needed, while the choice among possible layouts favors preferred boundaries. Of course, these numbers aren't precise measurements of how natural a person finds a break.
We've now looked at Core, which calculates line breaks from text and widths; React, which measures and displays them in the browser; and the ko/en models, which suggest more natural places to break. But I haven't even started talking about the trial and error of building this library yet haha... help. Covering all of that here would make the article too long (and honestly, writing this much is tiring me out too), so I'll continue with those experiences in the next article. To be continued ..