From 9a4ac3e9cd3826260db586ba1c5ca69801dc31fb Mon Sep 17 00:00:00 2001 From: Mahmoud Hamdi Date: Sun, 29 Mar 2026 04:04:26 +0200 Subject: [PATCH 1/2] fix(box): use string-width for correct emoji and CJK character alignment Replace `stripAnsi(str).length` with `stringWidth()` in box rendering to properly account for the visual width of emoji and CJK characters. This fixes misaligned right edges when box content contains characters that occupy more than one terminal column. --- src/utils/box.ts | 22 ++++++++++++++-------- test/box.test.ts | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 test/box.test.ts diff --git a/src/utils/box.ts b/src/utils/box.ts index e400a15a..c286d918 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -1,6 +1,15 @@ +import _stringWidth from "string-width"; import { getColor } from "./color"; import { stripAnsi } from "./string"; +function stringWidth(str: string) { + const hasICU = typeof Intl === "object"; + if (!hasICU || !Intl.Segmenter) { + return stripAnsi(str).length; + } + return _stringWidth(str); +} + export type BoxBorderStyle = { /** * Top left corner @@ -257,8 +266,8 @@ export function box(text: string, _opts: BoxOpts = {}) { const height = textLines.length + paddingOffset; const width = Math.max( - ...textLines.map((line) => stripAnsi(line).length), - opts.title ? stripAnsi(opts.title).length : 0, + ...textLines.map((line) => stringWidth(line)), + opts.title ? stringWidth(opts.title) : 0, ) + paddingOffset; const widthOffset = width + paddingOffset; @@ -273,13 +282,10 @@ export function box(text: string, _opts: BoxOpts = {}) { if (opts.title) { const title = _color ? _color(opts.title) : opts.title; const left = borderStyle.h.repeat( - Math.floor((width - stripAnsi(opts.title).length) / 2), + Math.floor((width - stringWidth(opts.title)) / 2), ); const right = borderStyle.h.repeat( - width - - stripAnsi(opts.title).length - - stripAnsi(left).length + - paddingOffset, + width - stringWidth(opts.title) - stripAnsi(left).length + paddingOffset, ); boxLines.push( `${leftSpace}${borderStyle.tl}${left}${title}${right}${borderStyle.tr}`, @@ -312,7 +318,7 @@ export function box(text: string, _opts: BoxOpts = {}) { // Text line const line = textLines[i - valignOffset]; const left = " ".repeat(paddingOffset); - const right = " ".repeat(width - stripAnsi(line).length); + const right = " ".repeat(width - stringWidth(line)); boxLines.push( `${leftSpace}${borderStyle.v}${left}${line}${right}${borderStyle.v}`, ); diff --git a/test/box.test.ts b/test/box.test.ts new file mode 100644 index 00000000..f516264b --- /dev/null +++ b/test/box.test.ts @@ -0,0 +1,37 @@ +import { describe, test, expect } from "vitest"; +import stringWidth from "string-width"; +import { box } from "../src/utils/box"; + +describe("box", () => { + test("renders a basic box", () => { + const result = box("Hello"); + const lines = result.split("\n").filter(Boolean); + const widths = lines.map((line) => stringWidth(line)); + const uniqueWidths = [...new Set(widths)]; + expect(uniqueWidths.length).toBe(1); + }); + + test("aligns box edges with emoji content", () => { + const result = box("Hello ๐ŸŒ World"); + const lines = result.split("\n").filter(Boolean); + const widths = lines.map((line) => stringWidth(line)); + const uniqueWidths = [...new Set(widths)]; + expect(uniqueWidths.length).toBe(1); + }); + + test("aligns box edges with CJK characters", () => { + const result = box("ใ“ใ‚“ใซใกใฏ"); + const lines = result.split("\n").filter(Boolean); + const widths = lines.map((line) => stringWidth(line)); + const uniqueWidths = [...new Set(widths)]; + expect(uniqueWidths.length).toBe(1); + }); + + test("aligns box edges with multiple emoji lines", () => { + const result = box("Line 1 ๐ŸŽ‰\nLine 2 ๐Ÿš€\nLine 3"); + const lines = result.split("\n").filter(Boolean); + const widths = lines.map((line) => stringWidth(line)); + const uniqueWidths = [...new Set(widths)]; + expect(uniqueWidths.length).toBe(1); + }); +}); From 3d9099fc0c3f2ecf7a7078f66ea37a172dc98fba Mon Sep 17 00:00:00 2001 From: Mahmoud Hamdi Date: Sun, 29 Mar 2026 04:10:47 +0200 Subject: [PATCH 2/2] refactor: extract stringWidth to shared utility, add edge case tests Move the duplicated stringWidth helper from both box.ts and fancy.ts into src/utils/string.ts as a shared export. Use stringWidth consistently for all width calculations in box rendering (including title border). Add tests for emoji titles, CJK titles, and ZWJ sequences. --- src/reporters/fancy.ts | 12 +---------- src/utils.ts | 1 + src/utils/box.ts | 13 ++---------- src/utils/string.ts | 19 ++++++++++++++++++ test/box.test.ts | 45 ++++++++++++++++++++++-------------------- 5 files changed, 47 insertions(+), 43 deletions(-) diff --git a/src/reporters/fancy.ts b/src/reporters/fancy.ts index 3c58b277..4ec3c274 100644 --- a/src/reporters/fancy.ts +++ b/src/reporters/fancy.ts @@ -1,11 +1,10 @@ -import _stringWidth from "string-width"; import isUnicodeSupported from "is-unicode-supported"; import { colors } from "../utils/color"; import { parseStack } from "../utils/error"; import { FormatOptions, LogObject } from "../types"; import { LogLevel, LogType } from "../constants"; import { BoxOpts, box } from "../utils/box"; -import { stripAnsi } from "../utils"; +import { stripAnsi, stringWidth } from "../utils"; import { BasicReporter } from "./basic"; export const TYPE_COLOR_MAP: { [k in LogType]?: string } = { @@ -37,15 +36,6 @@ const TYPE_ICONS: { [k in LogType]?: string } = { log: "", }; -function stringWidth(str: string) { - // https://github.com/unjs/consola/issues/204 - const hasICU = typeof Intl === "object"; - if (!hasICU || !Intl.Segmenter) { - return stripAnsi(str).length; - } - return _stringWidth(str); -} - export class FancyReporter extends BasicReporter { formatStack(stack: string, message: string, opts?: FormatOptions) { const indent = " ".repeat((opts?.errorLevel || 0) + 1); diff --git a/src/utils.ts b/src/utils.ts index 1ddf92c5..a33e2800 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,7 @@ export * from "./utils/box"; export * from "./utils/color"; export { stripAnsi, + stringWidth, centerAlign, rightAlign, leftAlign, diff --git a/src/utils/box.ts b/src/utils/box.ts index c286d918..2e45cf8f 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -1,14 +1,5 @@ -import _stringWidth from "string-width"; import { getColor } from "./color"; -import { stripAnsi } from "./string"; - -function stringWidth(str: string) { - const hasICU = typeof Intl === "object"; - if (!hasICU || !Intl.Segmenter) { - return stripAnsi(str).length; - } - return _stringWidth(str); -} +import { stringWidth } from "./string"; export type BoxBorderStyle = { /** @@ -285,7 +276,7 @@ export function box(text: string, _opts: BoxOpts = {}) { Math.floor((width - stringWidth(opts.title)) / 2), ); const right = borderStyle.h.repeat( - width - stringWidth(opts.title) - stripAnsi(left).length + paddingOffset, + width - stringWidth(opts.title) - stringWidth(left) + paddingOffset, ); boxLines.push( `${leftSpace}${borderStyle.tl}${left}${title}${right}${borderStyle.tr}`, diff --git a/src/utils/string.ts b/src/utils/string.ts index 11498016..9dc4fdc9 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -1,3 +1,5 @@ +import _stringWidth from "string-width"; + const ansiRegex = [ String.raw`[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)`, String.raw`(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))`, @@ -15,6 +17,23 @@ export function stripAnsi(text: string) { return text.replace(new RegExp(ansiRegex, "g"), ""); } +/** + * Calculates the visual width of a string in terminal columns, correctly handling + * emoji, CJK characters, and ANSI escape codes. Falls back to stripped ANSI length + * when `Intl.Segmenter` is not available. + * + * @param {string} str - The string to measure. + * @returns {number} The visual width of the string in terminal columns. + */ +export function stringWidth(str: string) { + // https://github.com/unjs/consola/issues/204 + const hasICU = typeof Intl === "object"; + if (!hasICU || !Intl.Segmenter) { + return stripAnsi(str).length; + } + return _stringWidth(str); +} + /** * Centers a string within a specified total width, padding it with spaces or another specified character. * If the string is longer than the total width, it is returned as is. diff --git a/test/box.test.ts b/test/box.test.ts index f516264b..d9553497 100644 --- a/test/box.test.ts +++ b/test/box.test.ts @@ -1,37 +1,40 @@ import { describe, test, expect } from "vitest"; -import stringWidth from "string-width"; +import { stringWidth } from "../src/utils/string"; import { box } from "../src/utils/box"; +function expectAlignedBox(result: string) { + const lines = result.split("\n").filter(Boolean); + const widths = lines.map((line) => stringWidth(line)); + const uniqueWidths = [...new Set(widths)]; + expect(uniqueWidths.length).toBe(1); +} + describe("box", () => { test("renders a basic box", () => { - const result = box("Hello"); - const lines = result.split("\n").filter(Boolean); - const widths = lines.map((line) => stringWidth(line)); - const uniqueWidths = [...new Set(widths)]; - expect(uniqueWidths.length).toBe(1); + expectAlignedBox(box("Hello")); }); test("aligns box edges with emoji content", () => { - const result = box("Hello ๐ŸŒ World"); - const lines = result.split("\n").filter(Boolean); - const widths = lines.map((line) => stringWidth(line)); - const uniqueWidths = [...new Set(widths)]; - expect(uniqueWidths.length).toBe(1); + expectAlignedBox(box("Hello ๐ŸŒ World")); }); test("aligns box edges with CJK characters", () => { - const result = box("ใ“ใ‚“ใซใกใฏ"); - const lines = result.split("\n").filter(Boolean); - const widths = lines.map((line) => stringWidth(line)); - const uniqueWidths = [...new Set(widths)]; - expect(uniqueWidths.length).toBe(1); + expectAlignedBox(box("ใ“ใ‚“ใซใกใฏ")); }); test("aligns box edges with multiple emoji lines", () => { - const result = box("Line 1 ๐ŸŽ‰\nLine 2 ๐Ÿš€\nLine 3"); - const lines = result.split("\n").filter(Boolean); - const widths = lines.map((line) => stringWidth(line)); - const uniqueWidths = [...new Set(widths)]; - expect(uniqueWidths.length).toBe(1); + expectAlignedBox(box("Line 1 ๐ŸŽ‰\nLine 2 ๐Ÿš€\nLine 3")); + }); + + test("aligns box edges with emoji in title", () => { + expectAlignedBox(box("Content", { title: "๐ŸŽ‰ Title" })); + }); + + test("aligns box edges with CJK title", () => { + expectAlignedBox(box("Content", { title: "ใ‚ฟใ‚คใƒˆใƒซ" })); + }); + + test("aligns box edges with ZWJ emoji sequences", () => { + expectAlignedBox(box("Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง World")); }); });