Skip to content

Commit dcc1116

Browse files
committed
perf: wrap fixed-width text in linear time
The paragraph is segmented once and each line is found by a galloping plus binary search over grapheme indices, measuring about one line per probe instead of prefixes of the whole remaining text (32k characters: 3.7 s to 76 ms). Per-character rendering measures glyphs on the drawing context instead of rebuilding a metrics object per glyph.
1 parent 2dbb0ae commit dcc1116

2 files changed

Lines changed: 117 additions & 92 deletions

File tree

src/shapes/Text.ts

Lines changed: 80 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,8 @@ export class Text extends Shape<TextConfig> {
389389
this._partialTextX = lineTranslateX;
390390
this._partialTextY = translateY + lineTranslateY;
391391
this._partialText = letter;
392-
const letterWidth = this.measureSize(letter).width;
392+
// the font of the shape is already set on the context
393+
const letterWidth = context.measureText(letter).width;
393394

394395
if (charRenderFunc) {
395396
context.save();
@@ -619,108 +620,95 @@ export class Text extends Shape<TextConfig> {
619620
* if width is fixed and line does not fit entirely
620621
* break the line into multiple fitting lines
621622
*/
622-
while (line.length > 0) {
623-
// Compute the grapheme array once per iteration. `line` is constant
624-
// within this block (only reassigned at the bottom), so calling
625-
// `stringToArray(line)` inside the binary search and again afterwards
626-
// is redundant and makes resize O(N·logN) on long text.
627-
const lineArray = stringToArray(line);
623+
const graphemes = stringToArray(line);
624+
const length = graphemes.length;
625+
let start = 0;
626+
const text = (end: number) => graphemes.slice(start, end).join('');
627+
const isBreak = (char: string) => char === SPACE || char === DASH;
628+
// graphemes per line, estimated from the average grapheme width
629+
const perLine = Math.max(1, Math.ceil((length * maxWidth) / lineWidth));
630+
while (start < length) {
631+
// only reserve the ellipsis width on a line that may be the last
632+
// visible one
633+
const extraWidth =
634+
shouldAddEllipsis &&
635+
fixedHeight &&
636+
currentHeightPx + lineHeightPx > maxHeightPx
637+
? additionalWidth
638+
: 0;
639+
// width of the longest fitting prefix found so far
640+
let matchWidth = 0;
641+
const fits = (end: number) => {
642+
const width = this._getTextWidth(text(end), end - start);
643+
if (width + extraWidth > maxWidth) {
644+
return false;
645+
}
646+
matchWidth = width;
647+
return true;
648+
};
628649
/*
629-
* use binary search to find the longest substring that
630-
* that would fit in the specified width
650+
* find the longest prefix that fits in the specified width:
651+
* grow a window from the estimate until it stops fitting, then
652+
* binary search inside it. Every probe measures about one line,
653+
* not the whole remaining text, so wrapping stays linear
631654
*/
632-
let low = 0,
633-
high = lineArray.length, // Convert to array for proper emoji handling
634-
match = '',
635-
matchWidth = 0;
636-
while (low < high) {
637-
const mid = (low + high) >>> 1,
638-
// Convert array indices to string
639-
substr = lineArray.slice(0, mid + 1).join(''),
640-
substrWidth = this._getTextWidth(substr);
641-
642-
// Only add ellipsis width when we need to consider truncation
643-
// for the current line (when it might be the last visible line)
644-
const shouldConsiderEllipsis =
645-
shouldAddEllipsis &&
646-
fixedHeight &&
647-
currentHeightPx + lineHeightPx > maxHeightPx;
648-
649-
const effectiveWidth = shouldConsiderEllipsis
650-
? substrWidth + additionalWidth
651-
: substrWidth;
652-
653-
if (effectiveWidth <= maxWidth) {
654-
low = mid + 1;
655-
match = substr;
656-
matchWidth = substrWidth; // Store actual text width without ellipsis
655+
let low = start,
656+
high = Math.min(length, start + perLine);
657+
while (fits(high)) {
658+
low = high;
659+
if (high === length) {
660+
break;
661+
}
662+
high = Math.min(length, high + (high - start));
663+
}
664+
while (high - low > 1) {
665+
const mid = (low + high) >>> 1;
666+
if (fits(mid)) {
667+
low = mid;
657668
} else {
658669
high = mid;
659670
}
660671
}
661-
/*
662-
* 'low' is now the index of the substring end
663-
* 'match' is the substring
664-
* 'matchWidth' is the substring width in px
665-
*/
666-
if (match) {
667-
// a fitting substring was found
668-
if (wrapAtWord) {
669-
// try to find a space or dash where wrapping could be done
670-
const matchArray = stringToArray(match);
671-
const nextChar = lineArray[matchArray.length];
672-
const nextIsSpaceOrDash = nextChar === SPACE || nextChar === DASH;
673-
674-
let wrapIndex;
675-
if (nextIsSpaceOrDash && matchWidth <= maxWidth) {
676-
wrapIndex = matchArray.length;
677-
} else {
678-
// Find last space or dash in the array
679-
const lastSpaceIndex = matchArray.lastIndexOf(SPACE);
680-
const lastDashIndex = matchArray.lastIndexOf(DASH);
681-
wrapIndex = Math.max(lastSpaceIndex, lastDashIndex) + 1;
682-
}
683-
684-
if (wrapIndex > 0) {
685-
low = wrapIndex;
686-
match = lineArray.slice(0, low).join('');
687-
matchWidth = this._getTextWidth(match);
688-
}
689-
}
690-
// if (align === 'right') {
691-
match = match.trimRight();
692-
// }
693-
this._addTextLine(match);
694-
textWidth = Math.max(textWidth, matchWidth);
672+
if (low === start) {
673+
// not even one character could fit in the element, abort
674+
break;
675+
}
676+
if (low === length) {
677+
// the rest of the paragraph fits on one line: kept untrimmed and
678+
// without the ellipsis check, like a paragraph that never wrapped
679+
this._addTextLine(text(length));
695680
currentHeightPx += lineHeightPx;
696-
697-
const shouldHandleEllipsis =
698-
this._shouldHandleEllipsis(currentHeightPx);
699-
if (shouldHandleEllipsis) {
700-
this._tryToAddEllipsisToLastLine();
701-
/*
702-
* stop wrapping if wrapping is disabled or if adding
703-
* one more line would overflow the fixed height
704-
*/
705-
break;
681+
textWidth = Math.max(textWidth, matchWidth);
682+
break;
683+
}
684+
if (wrapAtWord && !isBreak(graphemes[low])) {
685+
// wrap at the last space or dash of the line instead
686+
let wrapIndex = low - 1;
687+
while (wrapIndex >= start && !isBreak(graphemes[wrapIndex])) {
688+
wrapIndex--;
706689
}
707-
708-
// Reuse the cached `lineArray` to compute the remaining text.
709-
line = lineArray.slice(low).join('').trimLeft();
710-
711-
if (line.length > 0) {
712-
lineWidth = this._getTextWidth(line);
713-
if (lineWidth <= maxWidth) {
714-
this._addTextLine(line);
715-
currentHeightPx += lineHeightPx;
716-
textWidth = Math.max(textWidth, lineWidth);
717-
break;
718-
}
690+
if (wrapIndex >= start) {
691+
low = wrapIndex + 1;
692+
matchWidth = this._getTextWidth(text(low), low - start);
719693
}
720-
} else {
721-
// not even one character could fit in the element, abort
694+
}
695+
this._addTextLine(text(low).trimRight());
696+
textWidth = Math.max(textWidth, matchWidth);
697+
currentHeightPx += lineHeightPx;
698+
699+
if (this._shouldHandleEllipsis(currentHeightPx)) {
700+
this._tryToAddEllipsisToLastLine();
701+
/*
702+
* stop wrapping if wrapping is disabled or if adding
703+
* one more line would overflow the fixed height
704+
*/
722705
break;
723706
}
707+
// the next line starts after the whitespace of the break
708+
start = low;
709+
while (start < length && !graphemes[start].trim()) {
710+
start++;
711+
}
724712
}
725713
} else {
726714
// element width is automatically adjusted to max line width

test/unit/Text-test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,4 +2157,41 @@ describe('Text', function () {
21572157
assert.equal(text.getTextWidth(), text.measureSize('🇺🇸🇺🇸').width + 2 * 10);
21582158
});
21592159

2160+
it('wrapping measures a bounded amount of text per line', function () {
2161+
var text = new Konva.Text({
2162+
text: 'lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(
2163+
200
2164+
),
2165+
width: 300,
2166+
fontSize: 16,
2167+
});
2168+
const total = text.text().length;
2169+
let measured = 0;
2170+
const original = text._getTextWidth;
2171+
text._getTextWidth = function (str) {
2172+
measured += str.length;
2173+
return original.call(this, str);
2174+
};
2175+
text._setTextData();
2176+
assert.isAbove(text.textArr.length, 100, 'text wraps to many lines');
2177+
// every wrapped line should cost a few measurements of about its own
2178+
// length, not of the whole remaining text
2179+
assert.isBelow(measured, total * 60);
2180+
});
2181+
2182+
it('per-character rendering does not rebuild text metrics for every glyph', function () {
2183+
var stage = addStage();
2184+
var layer = new Konva.Layer();
2185+
var text = new Konva.Text({
2186+
text: 'Hello world, hello Konva',
2187+
fontSize: 30,
2188+
letterSpacing: 2,
2189+
});
2190+
layer.add(text);
2191+
stage.add(layer);
2192+
2193+
const calls = countCalls(text, 'measureSize', () => layer.draw());
2194+
// at most one call per pass (scene and hit) for the font ascent
2195+
assert.isAtMost(calls, 2);
2196+
});
21602197
});

0 commit comments

Comments
 (0)