From 1011d26f2b4c5a71f5f0e9a1f272a6cff5f88653 Mon Sep 17 00:00:00 2001 From: DanMat Date: Tue, 18 Aug 2026 07:29:35 -0400 Subject: [PATCH] fix(box): measure display width so emoji don't break borders box() used stripAnsi(line).length (UTF-16 code units), so emoji (surrogate pairs / ZWJ sequences) misaligned the border. Reuse the stringWidth helper (moved from the fancy reporter into utils/string so both share one impl) which accounts for wide/zero-width chars via Intl.Segmenter. Adds a box alignment test. Closes #402 --- src/reporters/fancy.ts | 12 +----------- src/utils.ts | 1 + src/utils/box.ts | 15 ++++++--------- src/utils/string.ts | 19 +++++++++++++++++++ test/box.test.ts | 19 +++++++++++++++++++ 5 files changed, 46 insertions(+), 20 deletions(-) create mode 100644 test/box.test.ts diff --git a/src/reporters/fancy.ts b/src/reporters/fancy.ts index 3c58b277..e8ae4619 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 { 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 e400a15a..2e45cf8f 100644 --- a/src/utils/box.ts +++ b/src/utils/box.ts @@ -1,5 +1,5 @@ import { getColor } from "./color"; -import { stripAnsi } from "./string"; +import { stringWidth } from "./string"; export type BoxBorderStyle = { /** @@ -257,8 +257,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 +273,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) - stringWidth(left) + paddingOffset, ); boxLines.push( `${leftSpace}${borderStyle.tl}${left}${title}${right}${borderStyle.tr}`, @@ -312,7 +309,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/src/utils/string.ts b/src/utils/string.ts index 11498016..7ddf953d 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"), ""); } +/** + * Returns the visual width of a string in terminal columns, accounting for ANSI + * escape codes and wide/emoji characters. Falls back to code-unit length when + * `Intl.Segmenter` is unavailable. + * + * @param {string} str - The string to measure. + * @returns {number} The number of columns the string occupies. + */ +export function stringWidth(str: string): number { + // 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 new file mode 100644 index 00000000..ee51049d --- /dev/null +++ b/test/box.test.ts @@ -0,0 +1,19 @@ +import { describe, test, expect } from "vitest"; +import { box } from "../src/utils/box"; +import { stringWidth } from "../src/utils/string"; + +describe("box", () => { + // https://github.com/unjs/consola/issues/402 + test("keeps borders aligned when content contains emoji", () => { + // "šŸ‘Øā€šŸ‘©ā€šŸ‘§" is a ZWJ sequence: many code units (`.length` overcounts badly) + // but only 2 display columns. With `.length` the border zig-zags. + const rendered = box("a short line\nšŸ‘Øā€šŸ‘©ā€šŸ‘§ family"); + const widths = rendered + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => stringWidth(line)); + + // every rendered line must share the same visual width + expect([...new Set(widths)]).toHaveLength(1); + }); +});