Skip to content

Commit

Permalink
TextTextureRenderer: Fix word wrap behavior that was regressed in 2.10.0
Browse files Browse the repository at this point in the history
Fixes #488
  • Loading branch information
frank-weindel committed Jun 6, 2023
1 parent 2e4c829 commit 2fc20c5
Show file tree
Hide file tree
Showing 4 changed files with 350 additions and 129 deletions.
79 changes: 22 additions & 57 deletions src/textures/TextTextureRenderer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import StageUtils from "../tree/StageUtils.mjs";
import Utils from "../tree/Utils.mjs";
import { getFontSetting, isZeroWidthSpace, splitWords } from "./TextTextureRendererUtils.mjs";
import { getFontSetting, measureText, wrapText } from "./TextTextureRendererUtils.mjs";

export default class TextTextureRenderer {

Expand Down Expand Up @@ -368,9 +368,9 @@ export default class TextTextureRenderer {
};

wrapWord(word, wordWrapWidth, suffix) {
const suffixWidth = this._context.measureText(suffix).width;
const suffixWidth = this.measureText(suffix);
const wordLen = word.length
const wordWidth = this._context.measureText(word).width;
const wordWidth = this.measureText(word);

/* If word fits wrapWidth, do nothing */
if (wordWidth <= wordWrapWidth) {
Expand All @@ -379,12 +379,12 @@ export default class TextTextureRenderer {

/* Make initial guess for text cuttoff */
let cutoffIndex = Math.floor((wordWrapWidth * wordLen) / wordWidth);
let truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth;
let truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth;

/* In case guess was overestimated, shrink it letter by letter. */
if (truncWordWidth > wordWrapWidth) {
while (cutoffIndex > 0) {
truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth;
truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth;
if (truncWordWidth > wordWrapWidth) {
cutoffIndex -= 1;
} else {
Expand All @@ -395,7 +395,7 @@ export default class TextTextureRenderer {
/* In case guess was underestimated, extend it letter by letter. */
} else {
while (cutoffIndex < wordLen) {
truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth;
truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth;
if (truncWordWidth < wordWrapWidth) {
cutoffIndex += 1;
} else {
Expand All @@ -411,62 +411,27 @@ export default class TextTextureRenderer {
}

/**
* Applies newlines to a string to have it optimally fit into the horizontal
* bounds set by the Text object's wordWrapWidth property.
* See {@link wrapText}
*
* @param {string} text
* @param {number} wordWrapWidth
* @param {number} letterSpacing
* @param {number} indent
* @returns
*/
wrapText(text, wordWrapWidth, letterSpacing, indent = 0) {
// Greedy wrapping algorithm that will wrap words as the line grows longer.
// than its horizontal bounds.
let lines = text.split(/\r?\n/g);
let allLines = [];
let realNewlines = [];
for (let i = 0; i < lines.length; i++) {
let resultLines = [];
let result = '';
let spaceLeft = wordWrapWidth - indent;
let words = splitWords(lines[i]);
for (let j = 0; j < words.length; j += 2) {
const space = words[j];
const word = words[j + 1];
const wordWidth = this.measureText(word, letterSpacing);
const spaceWidth = isZeroWidthSpace(space) ? 0 : this.measureText(space, letterSpacing);
const wordWidthWithSpace = wordWidth + spaceWidth;
if (j === 0 || wordWidthWithSpace > spaceLeft) {
// Skip printing the newline if it's the first word of the line that is.
// greater than the word wrap width.
if (j > 0) {
resultLines.push(result);
result = '';
}
result += word;
spaceLeft = wordWrapWidth - wordWidth - (j === 0 ? indent : 0);
}
else {
spaceLeft -= wordWidthWithSpace;
result += space + word;
}
}

resultLines.push(result);
result = '';

allLines = allLines.concat(resultLines);

if (i < lines.length - 1) {
realNewlines.push(allLines.length);
}
}

return {l: allLines, n: realNewlines};
return wrapText(this._context, text, wordWrapWidth, letterSpacing, indent);
};

/**
* See {@link measureText}
*
* @param {string} word
* @param {number} space
* @returns {number}
*/
measureText(word, space = 0) {
if (!space) {
return this._context.measureText(word).width;
}
return word.split('').reduce((acc, char) => {
return acc + this._context.measureText(char).width + space;
}, 0);
return measureText(this._context, word, space);
}

}
39 changes: 15 additions & 24 deletions src/textures/TextTextureRendererAdvanced.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import StageUtils from "../tree/StageUtils.mjs";
import Utils from "../tree/Utils.mjs";
import { getFontSetting, isSpace } from "./TextTextureRendererUtils.mjs";
import { getFontSetting, isSpace, measureText, tokenizeString } from "./TextTextureRendererUtils.mjs";

export default class TextTextureRendererAdvanced {

Expand Down Expand Up @@ -430,28 +430,19 @@ export default class TextTextureRendererAdvanced {

};

/**
* See {@link measureText}
*
* @param {string} word
* @param {number} space
* @returns {number}
*/
measureText(word, space = 0) {
if (!space) {
return this._context.measureText(word).width;
}
return word.split('').reduce((acc, char) => {
return acc + this._context.measureText(char).width + space;
}, 0);
return measureText(this._context, word, space);
}

tokenize(text) {
const re =/ |\u200B|\n|<i>|<\/i>|<b>|<\/b>|<color=0[xX][0-9a-fA-F]{8}>|<\/color>/g

const delimeters = text.match(re) || [];
const words = text.split(re) || [];

let final = [];
for (let i = 0; i < words.length; i++) {
final.push(words[i], delimeters[i])
}
final.pop()
return final.filter((word) => word != '');

return tokenizeString(/ |\u200B|\n|<i>|<\/i>|<b>|<\/b>|<color=0[xX][0-9a-fA-F]{8}>|<\/color>/g, text);
}

parse(tokens) {
Expand Down Expand Up @@ -541,9 +532,9 @@ export default class TextTextureRendererAdvanced {
}

wrapWord(word, wordWrapWidth, suffix) {
const suffixWidth = this._context.measureText(suffix).width;
const suffixWidth = this.measureText(suffix);
const wordLen = word.length
const wordWidth = this._context.measureText(word).width;
const wordWidth = this.measureText(word);

/* If word fits wrapWidth, do nothing */
if (wordWidth <= wordWrapWidth) {
Expand All @@ -552,12 +543,12 @@ export default class TextTextureRendererAdvanced {

/* Make initial guess for text cuttoff */
let cutoffIndex = Math.floor((wordWrapWidth * wordLen) / wordWidth);
let truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth;
let truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth;

/* In case guess was overestimated, shrink it letter by letter. */
if (truncWordWidth > wordWrapWidth) {
while (cutoffIndex > 0) {
truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth;
truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth;
if (truncWordWidth > wordWrapWidth) {
cutoffIndex -= 1;
} else {
Expand All @@ -568,7 +559,7 @@ export default class TextTextureRendererAdvanced {
/* In case guess was underestimated, extend it letter by letter. */
} else {
while (cutoffIndex < wordLen) {
truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth;
truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth;
if (truncWordWidth < wordWrapWidth) {
cutoffIndex += 1;
} else {
Expand Down
129 changes: 101 additions & 28 deletions src/textures/TextTextureRendererUtils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,33 +34,6 @@ export function getFontSetting(fontFace, fontStyle, fontSize, precision, default
return `${fontStyle} ${fontSize * precision}px ${ffs.join(",")}`
}

/**
* Splits a string into an array of spaces and words.
*
* @remarks
* This method always returns an array with an even length.
*
* - The **even indices** are the group of spaces that occur before the word at the
* next (odd) index.
* - The **odd indices** are the words.
*
* If the string does not begin with a space, the first element of the array will
* be an empty string ("").
*
* @param {string} text
* @returns
*/
export function splitWords(text) {
const regexp = /([ \u200B]+)?([^ \u200B]+)/g;
const arr = [];
let match;
while ((match = regexp.exec(text)) !== null) {
arr.push(match[1] || '');
arr.push(match[2]);
}
return arr;
}

/**
* Returns true if the given character is a zero-width space.
*
Expand All @@ -79,4 +52,104 @@ export function isZeroWidthSpace(space) {
*/
export function isSpace(space) {
return isZeroWidthSpace(space) || space === ' ';
}
}

/**
* Converts a string into an array of tokens and the words between them.
*
* @param {RegExp} tokenRegex
* @param {string} text
* @returns {string[]}
*/
export function tokenizeString(tokenRegex, text) {
const delimeters = text.match(tokenRegex) || [];
const words = text.split(tokenRegex) || [];

let final = [];
for (let i = 0; i < words.length; i++) {
final.push(words[i], delimeters[i])
}
final.pop()
return final.filter((word) => word != '');
}

/**
* Measure the width of a string accounting for letter spacing.
*
* @param {CanvasRenderingContext2D} context
* @param {string} word
* @param {number} space
* @returns
*/
export function measureText(context, word, space = 0) {
if (!space) {
return context.measureText(word).width;
}
return word.split('').reduce((acc, char) => {
// Zero-width spaces should not include letter spacing.
// And since we know the width of a zero-width space is 0, we can skip
// measuring it.
if (isZeroWidthSpace(char)) {
return acc;
}
return acc + context.measureText(char).width + space;
}, 0);
}

/**
* Applies newlines to a string to have it optimally fit into the horizontal
* bounds set by the Text object's wordWrapWidth property.
*
* @param {CanvasRenderingContext2D} context
* @param {string} text
* @param {number} wordWrapWidth
* @param {number} letterSpacing
* @param {number} indent
* @returns
*/
export function wrapText(context, text, wordWrapWidth, letterSpacing, indent) {
// Greedy wrapping algorithm that will wrap words as the line grows longer.
// than its horizontal bounds.
const spaceRegex = / |\u200B/g;
let lines = text.split(/\r?\n/g);
let allLines = [];
let realNewlines = [];
for (let i = 0; i < lines.length; i++) {
let resultLines = [];
let result = '';
let spaceLeft = wordWrapWidth - indent;
let words = lines[i].split(spaceRegex);
let spaces = lines[i].match(spaceRegex) || [];
for (let j = 0; j < words.length; j++) {
const space = spaces[j - 1] || '';
const word = words[j];
const wordWidth = measureText(context, word, letterSpacing);
const wordWidthWithSpace = wordWidth + measureText(context, space, letterSpacing);
if (j === 0 || wordWidthWithSpace > spaceLeft) {
// Skip printing the newline if it's the first word of the line that is.
// greater than the word wrap width.
if (j > 0) {
resultLines.push(result);
result = '';
}
result += word;
spaceLeft = wordWrapWidth - wordWidth - (j === 0 ? indent : 0);
}
else {
spaceLeft -= wordWidthWithSpace;
result += space + word;
}
}

resultLines.push(result);
result = '';

allLines = allLines.concat(resultLines);

if (i < lines.length - 1) {
realNewlines.push(allLines.length);
}
}

return {l: allLines, n: realNewlines};
}
Loading

0 comments on commit 2fc20c5

Please sign in to comment.