From 2bf8bce04a271c0adefd420db9e7c2e07a93c761 Mon Sep 17 00:00:00 2001 From: Yushion Yang <2685310462@qq.com> Date: Fri, 23 Oct 2026 18:00:36 +0800 Subject: [PATCH] Keep surrogate pairs intact when wrapping help text --- CHANGELOG.md | 5 +++++ lib/textwrap.js | 15 +++++++++++++-- test/test_port.js | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad9424d..d923b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +### Fixed +- Kept UTF-16 surrogate pairs together when wrapping long help text. + + ## [3.0.3] - 2026-10-16 ### Security - Fixed ReDoS in `int` type conversion. Not critical for typical CLI use: diff --git a/lib/textwrap.js b/lib/textwrap.js index e3248f8..9027bc4 100644 --- a/lib/textwrap.js +++ b/lib/textwrap.js @@ -164,8 +164,19 @@ class TextWrapper { // If we're allowed to break long words, then do so: put as much // of the next chunk onto the current line as will fit. if (this.break_long_words) { - cur_line.push(reversed_chunks[reversed_chunks.length - 1].slice(0, space_left)) - reversed_chunks[reversed_chunks.length - 1] = reversed_chunks[reversed_chunks.length - 1].slice(space_left) + const chunk = reversed_chunks[reversed_chunks.length - 1] + space_left = Math.floor(space_left) + const high = chunk.charCodeAt(space_left - 1) + const low = chunk.charCodeAt(space_left) + if (high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff) { + // Keep surrogate pairs together. Allow a whole pair on an + // empty line when the available width is only one. + space_left += space_left === 1 && cur_len === 0 ? 1 : -1 + } + if (space_left === 0) return + cur_line.push(chunk.slice(0, space_left)) + reversed_chunks[reversed_chunks.length - 1] = chunk.slice(space_left) + if (!reversed_chunks[reversed_chunks.length - 1]) reversed_chunks.pop() // Otherwise, we have to preserve the long word intact. Only add // it to the current line if there's nothing already there -- diff --git a/test/test_port.js b/test/test_port.js index af1925b..24aa673 100644 --- a/test/test_port.js +++ b/test/test_port.js @@ -6,6 +6,7 @@ const assert = require('assert') const { describe, it } = require('node:test') const argparse = require('../') +const textwrap = require('../lib/textwrap') // Number conversion: CPython calls the int() / float() builtins, the port @@ -81,3 +82,37 @@ for (const type of ['int', 'float']) { } }) } + + +describe('UTF-16 line wrapping', () => { + for (const [text, width, expected] of [ + ['😀😀😀', 3, ['😀', '😀', '😀']], + ['a😀😀b', 2, ['a', '😀', '😀', 'b']], + ['😀😀', 1, ['😀', '😀']], + ['😀😀', 1.5, ['😀', '😀']], + ['ab 😀😀😀', 4, ['ab', '😀😀', '😀']], + ['a😀b', 3, ['a😀', 'b']], + ['abcd', 2, ['ab', 'cd']] + ]) { + it(`keeps characters intact at width ${width}: ${text}`, () => { + assert.deepStrictEqual(textwrap.wrap(text, { width }), expected) + }) + } + + it('keeps a help description well formed', () => { + class NarrowFormatter extends argparse.HelpFormatter { + constructor (options) { + super({ ...options, width: 23 }) + } + } + const parser = new argparse.ArgumentParser({ + prog: 'demo', + add_help: false, + color: false, + formatter_class: NarrowFormatter, + description: '😀'.repeat(20) + }) + assert.strictEqual(parser.format_help(), + 'usage: demo\n\n' + '😀'.repeat(11) + '\n' + '😀'.repeat(9) + '\n') + }) +})