From 94a197703b348b4351a15df4ae6d436de45ef5af Mon Sep 17 00:00:00 2001 From: sujipar Date: Tue, 27 Oct 2026 19:42:53 +0900 Subject: [PATCH] fix: wrap by display width so full-width text is not dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textWrap() compared line.length against maxLength, which is a display width, and sliced by code units. A line of full-width characters is twice as wide as its length, so the function emitted lines wider than the cell; truncate() then cut the overflow and replaced it with an ellipsis, so the characters were lost from the output entirely. With colWidths: [14], wordWrap: true, wrapOnWordBoundary: false, a 14-character Korean string came back as 7 characters. The same options preserve all 24 characters of an ASCII string, so the loss was specific to wide text. Measure with strlen() and slice with truncateWidth() — both already in this file, which already imports string-width. ASCII takes truncateWidth's fast path and is unchanged. The guard for an empty head is new: truncateWidth() returns when even one character is too wide for the budget (a full-width character in a single column), which would stop the loop from making progress. Co-Authored-By: Claude Opus 5 --- src/utils.js | 9 ++++++--- test/table-test.js | 20 ++++++++++++++++++++ test/utils-test.js | 13 +++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/utils.js b/src/utils.js index 0f7de6c..8e6fe4b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -286,9 +286,12 @@ function textWrap(maxLength, input) { function pushLine(str, ws) { if (line.length && ws) line += ws; line += str; - while (line.length > maxLength) { - lines.push(line.slice(0, maxLength)); - line = line.slice(maxLength); + while (strlen(line) > maxLength) { + let head = truncateWidth(line, maxLength); + // a full-width char cannot fit in a single column, take it anyway so the loop makes progress + if (!head.length) head = line.slice(0, 1); + lines.push(head); + line = line.slice(head.length); } } let split = input.split(/(\s+)/g); diff --git a/test/table-test.js b/test/table-test.js index 6bd4e8e..33324a3 100644 --- a/test/table-test.js +++ b/test/table-test.js @@ -19,6 +19,26 @@ describe('@api Table ', function () { expect(table.toString()).toEqual(expected.join('\n')); }); + it('wordWrap without word boundaries does not drop full-width chars', function () { + let table = new Table({ colWidths: [14], wordWrap: true, wrapOnWordBoundary: false }); + + table.push(['가나다라마바사아자차카타파하']); + + let expected = ['┌──────────────┐', '│ 가나다라마바 │', '│ 사아자차카타 │', '│ 파하 │', '└──────────────┘']; + + expect(table.toString()).toEqual(expected.join('\n')); + }); + + it('wordWrap without word boundaries keeps ascii behaviour', function () { + let table = new Table({ colWidths: [14], wordWrap: true, wrapOnWordBoundary: false }); + + table.push(['abcdefghijklmnopqrstuvwx']); + + let expected = ['┌──────────────┐', '│ abcdefghijkl │', '│ mnopqrstuvwx │', '└──────────────┘']; + + expect(table.toString()).toEqual(expected.join('\n')); + }); + it('allows numbers as `content` property of cells defined using object notation', function () { let table = new Table({ style: { border: [], head: [] } }); diff --git a/test/utils-test.js b/test/utils-test.js index 6c02cac..218a15e 100644 --- a/test/utils-test.js +++ b/test/utils-test.js @@ -333,6 +333,19 @@ describe('utils', function () { 'stuv wx yz', ]); }); + + it('wraps long full-width words by display width', function () { + // 12 columns fit 6 full-width chars, not 12 + expect(wordWrap(12, '가나다라마바사아자차카타파하', false)).toEqual(['가나다라마바', '사아자차카타', '파하']); + expect(wordWrap(12, '这是一段没有空格的中文', false)).toEqual(['这是一段没有', '空格的中文']); + }); + + it('never returns a line wider than maxLength', function () { + let lines = wordWrap(12, '가나다라마바사아자차카타파하', false); + lines.forEach(function (line) { + expect(strlen(line)).toBeLessThanOrEqual(12); + }); + }); }); });