Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: the text wrapping position is not accurate #1876

Merged
merged 1 commit into from
Dec 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silver-tigers-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@antv/g-lite': patch
---

fix: the text wrapping position is not accurate
44 changes: 44 additions & 0 deletions __tests__/demos/bugfix/textWordWrap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Canvas, Text, Rect } from '@antv/g';
import * as tinybench from 'tinybench';

/**
* @link https://github.com/antvis/G/issues/1833
*/
export async function textWordWrap(context: { canvas: Canvas }) {
const { canvas } = context;
await canvas.ready;
Expand Down Expand Up @@ -105,4 +109,44 @@ export async function textWordWrap(context: { canvas: Canvas }) {
canvas.appendChild(rect1);
canvas.appendChild(text2);
canvas.appendChild(rect2);

// benchmark
// ----------
const bench = new tinybench.Bench({
name: 'canvas text benchmark',
time: 100,
});

const canvasEl = document.createElement('canvas');
const testText = 'Hello, World!';
bench.add('Measure the entire text at once', async () => {
canvasEl.getContext('2d').measureText(testText);
});
bench.add('Character-by-character measurement', async () => {
const ctx = canvasEl.getContext('2d');
Array.from(testText).forEach((char) => {
ctx.measureText(char);
});
});

const testText1 =
'In G, text line break detection is currently done by iteratively measuring the width of each character and then adding them up to determine whether a line break is needed. External users may configure wordWrapWidth by directly measuring the width of the entire text. The two different text measurement methods will lead to visual inconsistencies.';
bench.add('(long txt) Measure the entire text at once', async () => {
canvasEl.getContext('2d').measureText(testText1);
});
bench.add('(long txt) Character-by-character measurement', async () => {
const ctx = canvasEl.getContext('2d');
Array.from(testText1).forEach((char) => {
ctx.measureText(char);
});
});

await bench.run();

console.log(bench.name);
console.table(bench.table());
console.log(bench.results);
console.log(bench.tasks);

// ----------
}
4 changes: 2 additions & 2 deletions __tests__/demos/event/hierarchy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Circle, Path } from '@antv/g';
import { Canvas, Circle, Path } from '@antv/g';

export async function hierarchy(context) {
export async function hierarchy(context: { canvas: Canvas }) {
const { canvas } = context;
await canvas.ready;

Expand Down
136 changes: 91 additions & 45 deletions packages/g-lite/src/services/TextService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,108 +355,152 @@ export class TextService {
ellipsis = textOverflow;
}

const chars = Array.from(text);
let lines: string[] = [];
let currentIndex = 0;
let currentWidth = 0;
let currentLineIndex = 0;
let currentLineWidth = 0;

const cache: { [key in string]: number } = {};
const calcWidth = (char: string): number => {
const calcWidth = (txt: string): number => {
return this.getFromCache(
char,
txt,
letterSpacing,
cache,
context as CanvasRenderingContext2D,
);
};
const ellipsisWidth = Array.from(ellipsis).reduce((prev, cur) => {
return prev + calcWidth(cur);
}, 0);
const ellipsisWidth = calcWidth(ellipsis);

/**
* Find text fragments that will take up as much of the given line width as possible when rendered.
*
* @see https://github.com/antvis/G/issues/1833
*
* @param txt - Current line of text
* @param textCharIndex - The index of the last character of the current line in the entire text
*/
function findCharIndexClosestWidthThreshold(
txt: string,
textCharIndex: number,
widthThreshold: number,
) {
while (
calcWidth(txt) < widthThreshold &&
wang1212 marked this conversation as resolved.
Show resolved Hide resolved
textCharIndex < chars.length - 1
) {
textCharIndex += 1;
txt += chars[textCharIndex];
}
while (calcWidth(txt) > widthThreshold) {
textCharIndex -= 1;
txt = txt.slice(0, -1);
}

function appendEllipsis(lineIndex: number) {
return {
txt,
textCharIndex,
};
}

function appendEllipsis(lineIndex: number, textCharIndex: number) {
// If there is not enough space to display the string itself, it is clipped.
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/text-overflow#values
if (ellipsisWidth <= 0 || ellipsisWidth > maxWidth) {
return;
}

// Backspace from line's end.
const currentLineLength = lines[lineIndex] ? lines[lineIndex].length : 0;
let lastLineWidth = 0;
let lastLineIndex = currentLineLength;
for (let i = 0; i < currentLineLength; i++) {
const width = calcWidth(lines[lineIndex][i]);
if (lastLineWidth + width + ellipsisWidth > maxWidth) {
lastLineIndex = i;
break;
}
if (!lines[lineIndex]) {
lines[lineIndex] = ellipsis;

lastLineWidth += width;
return;
}

lines[lineIndex] =
(lines[lineIndex] || '').slice(0, lastLineIndex) + ellipsis;
const result = findCharIndexClosestWidthThreshold(
lines[lineIndex],
textCharIndex,
maxWidth - ellipsisWidth,
);

lines[lineIndex] = result.txt + ellipsis;
}

const chars = Array.from(text);
for (let i = 0; i < chars.length; i++) {
const char = chars[i];

const prevChar = text[i - 1];
const nextChar = text[i + 1];
const charWidth = calcWidth(char);
let char = chars[i];
let prevChar = chars[i - 1];
let nextChar = chars[i + 1];
let charWidth = calcWidth(char);

if (this.isNewline(char)) {
currentIndex++;

// exceed maxLines, break immediately
if (currentIndex >= maxLines) {
if (currentLineIndex + 1 >= maxLines) {
parsedStyle.isOverflowing = true;

if (i < chars.length - 1) {
appendEllipsis(currentIndex - 1);
appendEllipsis(currentLineIndex, i);
}

break;
}

currentWidth = 0;
lines[currentIndex] = '';
currentLineIndex += 1;
currentLineWidth = 0;
lines[currentLineIndex] = '';

continue;
}

if (currentWidth > 0 && currentWidth + charWidth > maxWidth) {
if (currentIndex + 1 >= maxLines) {
if (currentLineWidth > 0 && currentLineWidth + charWidth > maxWidth) {
const result = findCharIndexClosestWidthThreshold(
lines[currentLineIndex],
i,
maxWidth,
);
if (result.textCharIndex !== i) {
lines[currentLineIndex] = result.txt;

if (result.textCharIndex === chars.length - 1) {
break;
}

i = result.textCharIndex;
char = chars[i];
prevChar = chars[i - 1];
nextChar = chars[i + 1];
charWidth = calcWidth(char);
}

if (currentLineIndex + 1 >= maxLines) {
parsedStyle.isOverflowing = true;

appendEllipsis(currentIndex);
appendEllipsis(currentLineIndex, i);

break;
}

currentIndex++;
currentWidth = 0;
lines[currentIndex] = '';
currentLineIndex += 1;
currentLineWidth = 0;
lines[currentLineIndex] = '';

if (this.isBreakingSpace(char)) {
continue;
}

if (!this.canBreakInLastChar(char)) {
lines = this.trimToBreakable(lines);
currentWidth = this.sumTextWidthByCache(
lines[currentIndex] || '',
currentLineWidth = this.sumTextWidthByCache(
lines[currentLineIndex] || '',
cache,
);
}

if (this.shouldBreakByKinsokuShorui(char, nextChar)) {
lines = this.trimByKinsokuShorui(lines);
currentWidth += calcWidth(prevChar || '');
currentLineWidth += calcWidth(prevChar || '');
}
}

currentWidth += charWidth;
lines[currentIndex] = (lines[currentIndex] || '') + char;
currentLineWidth += charWidth;
lines[currentLineIndex] = (lines[currentLineIndex] || '') + char;
}

return lines.join('\n');
Expand Down Expand Up @@ -554,7 +598,9 @@ export class TextService {
let width = cache[key];
if (typeof width !== 'number') {
const spacing = key.length * letterSpacing;
width = context.measureText(key).width + spacing;
const metrics = context.measureText(key);

width = metrics.width + spacing;
cache[key] = width;
}
return width;
Expand Down
Loading