textwrap.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. """
  2. Sequence-aware text wrapping functions.
  3. This module provides functions for wrapping text that may contain terminal escape sequences, with
  4. proper handling of Unicode grapheme clusters and character display widths.
  5. """
  6. from __future__ import annotations
  7. # std imports
  8. import re
  9. import secrets
  10. import textwrap
  11. from typing import TYPE_CHECKING, NamedTuple
  12. # local
  13. from .wcwidth import width as _width
  14. from .wcwidth import iter_sequences
  15. from .grapheme import iter_graphemes
  16. from .sgr_state import propagate_sgr as _propagate_sgr
  17. from .escape_sequences import ZERO_WIDTH_PATTERN
  18. if TYPE_CHECKING: # pragma: no cover
  19. from typing import Any, Literal
  20. class _HyperlinkState(NamedTuple):
  21. """State for tracking an open OSC 8 hyperlink across line breaks."""
  22. url: str # hyperlink target URL
  23. params: str # id=xxx and other key=value pairs separated by :
  24. terminator: str # BEL (\x07) or ST (\x1b\\)
  25. # Hyperlink parsing: captures (params, url, terminator)
  26. _HYPERLINK_OPEN_RE = re.compile(r'\x1b]8;([^;]*);([^\x07\x1b]*)(\x07|\x1b\\)')
  27. def _parse_hyperlink_open(seq: str) -> _HyperlinkState | None:
  28. """Parse OSC 8 open sequence, return state or None."""
  29. if (m := _HYPERLINK_OPEN_RE.match(seq)):
  30. return _HyperlinkState(url=m.group(2), params=m.group(1), terminator=m.group(3))
  31. return None
  32. def _make_hyperlink_open(url: str, params: str, terminator: str) -> str:
  33. """Generate OSC 8 open sequence."""
  34. return f'\x1b]8;{params};{url}{terminator}'
  35. def _make_hyperlink_close(terminator: str) -> str:
  36. """Generate OSC 8 close sequence."""
  37. return f'\x1b]8;;{terminator}'
  38. class SequenceTextWrapper(textwrap.TextWrapper):
  39. """
  40. Sequence-aware text wrapper extending :class:`textwrap.TextWrapper`.
  41. This wrapper properly handles terminal escape sequences and Unicode grapheme clusters when
  42. calculating text width for wrapping.
  43. This implementation is based on the SequenceTextWrapper from the 'blessed' library, with
  44. contributions from Avram Lubkin and grayjk.
  45. The key difference from the blessed implementation is the addition of grapheme cluster support
  46. via :func:`~.iter_graphemes`, providing width calculation for ZWJ emoji sequences, VS-16 emojis
  47. and variations, regional indicator flags, and combining characters.
  48. OSC 8 hyperlinks are handled specially: when a hyperlink must span multiple lines, each line
  49. receives complete open/close sequences with a shared ``id`` parameter, ensuring terminals
  50. treat the fragments as a single hyperlink for hover underlining. If the original hyperlink
  51. already has an ``id`` parameter, it is preserved; otherwise, one is generated.
  52. """
  53. def __init__(self, width: int = 70, *,
  54. control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
  55. tabsize: int = 8,
  56. ambiguous_width: int = 1,
  57. **kwargs: Any) -> None:
  58. """
  59. Initialize the wrapper.
  60. :param width: Maximum line width in display cells.
  61. :param control_codes: How to handle control sequences (see :func:`~.width`).
  62. :param tabsize: Tab stop width for tab expansion.
  63. :param ambiguous_width: Width to use for East Asian Ambiguous (A) characters.
  64. :param kwargs: Additional arguments passed to :class:`textwrap.TextWrapper`.
  65. """
  66. super().__init__(width=width, **kwargs)
  67. self.control_codes = control_codes
  68. self.tabsize = tabsize
  69. self.ambiguous_width = ambiguous_width
  70. @staticmethod
  71. def _next_hyperlink_id() -> str:
  72. """Generate unique hyperlink id as 8-character hex string."""
  73. return secrets.token_hex(4)
  74. def _width(self, text: str) -> int:
  75. """Measure text width accounting for sequences."""
  76. return _width(text, control_codes=self.control_codes, tabsize=self.tabsize,
  77. ambiguous_width=self.ambiguous_width)
  78. def _strip_sequences(self, text: str) -> str:
  79. """Strip all terminal sequences from text."""
  80. result = []
  81. for segment, is_seq in iter_sequences(text):
  82. if not is_seq:
  83. result.append(segment)
  84. return ''.join(result)
  85. def _extract_sequences(self, text: str) -> str:
  86. """Extract only terminal sequences from text."""
  87. result = []
  88. for segment, is_seq in iter_sequences(text):
  89. if is_seq:
  90. result.append(segment)
  91. return ''.join(result)
  92. def _split(self, text: str) -> list[str]: # pylint: disable=too-many-locals
  93. r"""
  94. Sequence-aware variant of :meth:`textwrap.TextWrapper._split`.
  95. This method ensures that terminal escape sequences don't interfere with the text splitting
  96. logic, particularly for hyphen-based word breaking. It builds a position mapping from
  97. stripped text to original text, calls the parent's _split on stripped text, then maps chunks
  98. back.
  99. OSC hyperlink sequences are treated as word boundaries::
  100. >>> wrap('foo \x1b]8;;https://example.com\x07link\x1b]8;;\x07 bar', 6)
  101. ['foo', '\x1b]8;;https://example.com\x07link\x1b]8;;\x07', 'bar']
  102. Both BEL (``\x07``) and ST (``\x1b\\``) terminators are supported.
  103. """
  104. # pylint: disable=too-many-locals,too-many-branches
  105. # Build a mapping from stripped text positions to original text positions.
  106. #
  107. # Track where each character ENDS so that sequences between characters
  108. # attach to the following text (not preceding text). This ensures sequences
  109. # aren't lost when whitespace is dropped.
  110. #
  111. # char_end[i] = position in original text right after the i-th stripped char
  112. char_end: list[int] = []
  113. stripped_text = ''
  114. original_pos = 0
  115. prev_was_hyperlink_close = False
  116. for segment, is_seq in iter_sequences(text):
  117. if not is_seq:
  118. # Conditionally insert space after hyperlink close to force word boundary
  119. if prev_was_hyperlink_close and segment and not segment[0].isspace():
  120. stripped_text += ' '
  121. char_end.append(original_pos)
  122. for char in segment:
  123. original_pos += 1
  124. char_end.append(original_pos)
  125. stripped_text += char
  126. prev_was_hyperlink_close = False
  127. else:
  128. is_hyperlink_close = segment.startswith(('\x1b]8;;\x1b\\', '\x1b]8;;\x07'))
  129. # Conditionally insert space before OSC sequences to artificially create word
  130. # boundary, but *not* before hyperlink close sequences, to ensure hyperlink is
  131. # terminated on the same line.
  132. if (segment.startswith('\x1b]') and stripped_text and not
  133. stripped_text[-1].isspace()):
  134. if not is_hyperlink_close:
  135. stripped_text += ' '
  136. char_end.append(original_pos)
  137. # Escape sequences advance position but don't add to stripped text
  138. original_pos += len(segment)
  139. prev_was_hyperlink_close = is_hyperlink_close
  140. # Add sentinel for final position
  141. char_end.append(original_pos)
  142. # Use parent's _split on the stripped text
  143. # pylint: disable-next=protected-access
  144. stripped_chunks = textwrap.TextWrapper._split(self, stripped_text)
  145. # Handle text that contains only sequences (no visible characters).
  146. # Return the sequences as a single chunk to preserve them.
  147. if not stripped_chunks and text:
  148. return [text]
  149. # Map the chunks back to the original text with sequences
  150. result: list[str] = []
  151. stripped_pos = 0
  152. num_chunks = len(stripped_chunks)
  153. for idx, chunk in enumerate(stripped_chunks):
  154. chunk_len = len(chunk)
  155. # Start is where previous character ended (or 0 for first chunk)
  156. start_orig = 0 if stripped_pos == 0 else char_end[stripped_pos - 1]
  157. # End is where next character starts. For last chunk, use sentinel
  158. # to include any trailing sequences.
  159. if idx == num_chunks - 1:
  160. end_orig = char_end[-1] # sentinel includes trailing sequences
  161. else:
  162. end_orig = char_end[stripped_pos + chunk_len - 1]
  163. # Extract the corresponding portion from the original text
  164. # Skip empty chunks (from virtual spaces inserted at OSC boundaries)
  165. if start_orig != end_orig:
  166. result.append(text[start_orig:end_orig])
  167. stripped_pos += chunk_len
  168. return result
  169. def _wrap_chunks(self, chunks: list[str]) -> list[str]: # pylint: disable=too-many-branches
  170. """
  171. Wrap chunks into lines using sequence-aware width.
  172. Override TextWrapper._wrap_chunks to use _width instead of len. Follows stdlib's algorithm:
  173. greedily fill lines, handle long words. Also handle OSC hyperlink processing. When
  174. hyperlinks span multiple lines, each line gets complete open/close sequences with matching
  175. id parameters for hover underlining continuity per OSC 8 spec.
  176. """
  177. # pylint: disable=too-many-branches,too-many-statements,too-complex,too-many-locals
  178. # pylint: disable=too-many-nested-blocks
  179. # the hyperlink code in particular really pushes the complexity rating of this method.
  180. # preferring to keep it "all in one method" because of so much local state and manipulation.
  181. if not chunks:
  182. return []
  183. if self.max_lines is not None:
  184. if self.max_lines > 1:
  185. indent = self.subsequent_indent
  186. else:
  187. indent = self.initial_indent
  188. if (self._width(indent)
  189. + self._width(self.placeholder.lstrip())
  190. > self.width):
  191. raise ValueError("placeholder too large for max width")
  192. lines: list[str] = []
  193. is_first_line = True
  194. hyperlink_state: _HyperlinkState | None = None
  195. # Track the id we're using for the current hyperlink continuation
  196. current_hyperlink_id: str | None = None
  197. # Arrange in reverse order so items can be efficiently popped
  198. chunks = list(reversed(chunks))
  199. while chunks:
  200. current_line: list[str] = []
  201. current_width = 0
  202. # Get the indent and available width for current line
  203. indent = self.initial_indent if is_first_line else self.subsequent_indent
  204. line_width = self.width - self._width(indent)
  205. # If continuing a hyperlink from previous line, prepend open sequence
  206. if hyperlink_state is not None:
  207. open_seq = _make_hyperlink_open(
  208. hyperlink_state.url, hyperlink_state.params, hyperlink_state.terminator)
  209. chunks[-1] = open_seq + chunks[-1]
  210. # Drop leading whitespace (except at very start)
  211. # When dropping, transfer any sequences to the next chunk.
  212. # Only drop if there's actual whitespace text, not if it's only sequences.
  213. stripped = self._strip_sequences(chunks[-1])
  214. if self.drop_whitespace and lines and stripped and not stripped.strip():
  215. sequences = self._extract_sequences(chunks[-1])
  216. del chunks[-1]
  217. if sequences and chunks:
  218. chunks[-1] = sequences + chunks[-1]
  219. # Greedily add chunks that fit
  220. while chunks:
  221. chunk = chunks[-1]
  222. chunk_width = self._width(chunk)
  223. if current_width + chunk_width <= line_width:
  224. current_line.append(chunks.pop())
  225. current_width += chunk_width
  226. else:
  227. break
  228. # Handle chunk that's too long for any line
  229. if chunks and self._width(chunks[-1]) > line_width:
  230. self._handle_long_word(
  231. chunks, current_line, current_width, line_width
  232. )
  233. current_width = self._width(''.join(current_line))
  234. # Remove any empty chunks left by _handle_long_word
  235. while chunks and not chunks[-1]:
  236. del chunks[-1]
  237. # Drop trailing whitespace
  238. # When dropping, transfer any sequences to the previous chunk.
  239. # Only drop if there's actual whitespace text, not if it's only sequences.
  240. stripped_last = self._strip_sequences(current_line[-1]) if current_line else ''
  241. if (self.drop_whitespace and current_line and
  242. stripped_last and not stripped_last.strip()):
  243. sequences = self._extract_sequences(current_line[-1])
  244. current_width -= self._width(current_line[-1])
  245. del current_line[-1]
  246. if sequences and current_line:
  247. current_line[-1] = current_line[-1] + sequences
  248. if current_line:
  249. # Check whether this is a normal append or max_lines
  250. # truncation. Matches stdlib textwrap precedence:
  251. # normal if max_lines not set, not yet reached, or no
  252. # remaining visible content that would need truncation.
  253. no_more_content = (
  254. not chunks or
  255. self.drop_whitespace and
  256. len(chunks) == 1 and
  257. not self._strip_sequences(chunks[0]).strip()
  258. )
  259. if (self.max_lines is None or
  260. len(lines) + 1 < self.max_lines or
  261. no_more_content
  262. and current_width <= line_width):
  263. line_content = ''.join(current_line)
  264. # Track hyperlink state through this line's content
  265. new_state = self._track_hyperlink_state(line_content, hyperlink_state)
  266. # If we end inside a hyperlink, append close sequence
  267. if new_state is not None:
  268. # Ensure we have an id for continuation
  269. if current_hyperlink_id is None:
  270. if 'id=' in new_state.params:
  271. current_hyperlink_id = new_state.params
  272. elif new_state.params:
  273. # Prepend id to existing params (per OSC 8 spec, params can have
  274. # multiple key=value pairs separated by :)
  275. current_hyperlink_id = (
  276. f'id={self._next_hyperlink_id()}:{new_state.params}')
  277. else:
  278. current_hyperlink_id = f'id={self._next_hyperlink_id()}'
  279. line_content += _make_hyperlink_close(new_state.terminator)
  280. # Also need to inject the id into the opening
  281. # sequence if it didn't have one
  282. if 'id=' not in new_state.params:
  283. # Find and replace the original open sequence with one that has id
  284. old_open = _make_hyperlink_open(
  285. new_state.url, new_state.params, new_state.terminator)
  286. new_open = _make_hyperlink_open(
  287. new_state.url, current_hyperlink_id, new_state.terminator)
  288. line_content = line_content.replace(old_open, new_open, 1)
  289. # Update state for next line, using computed id
  290. hyperlink_state = _HyperlinkState(
  291. new_state.url, current_hyperlink_id, new_state.terminator)
  292. else:
  293. hyperlink_state = None
  294. current_hyperlink_id = None # Reset id when hyperlink closes
  295. # Strip trailing whitespace when drop_whitespace is enabled
  296. # (matches CPython #140627 fix behavior)
  297. if self.drop_whitespace:
  298. line_content = line_content.rstrip()
  299. lines.append(indent + line_content)
  300. is_first_line = False
  301. else:
  302. # max_lines reached with remaining content —
  303. # pop chunks until placeholder fits, then break.
  304. placeholder_w = self._width(self.placeholder)
  305. while current_line:
  306. last_text = self._strip_sequences(current_line[-1])
  307. if (last_text.strip()
  308. and current_width + placeholder_w <= line_width):
  309. line_content = ''.join(current_line)
  310. new_state = self._track_hyperlink_state(
  311. line_content, hyperlink_state)
  312. if new_state is not None:
  313. line_content += _make_hyperlink_close(
  314. new_state.terminator)
  315. lines.append(indent + line_content + self.placeholder)
  316. break
  317. current_width -= self._width(current_line[-1])
  318. del current_line[-1]
  319. else:
  320. if lines:
  321. prev_line = self._rstrip_visible(lines[-1])
  322. if (self._width(prev_line) + placeholder_w
  323. <= self.width):
  324. lines[-1] = prev_line + self.placeholder
  325. break
  326. lines.append(indent + self.placeholder.lstrip())
  327. break
  328. return lines
  329. def _track_hyperlink_state(
  330. self, text: str,
  331. state: _HyperlinkState | None) -> _HyperlinkState | None:
  332. """
  333. Track hyperlink state through text.
  334. :param text: Text to scan for hyperlink sequences.
  335. :param state: Current state or None if outside hyperlink.
  336. :returns: Updated state after processing text.
  337. """
  338. for segment, is_seq in iter_sequences(text):
  339. if is_seq:
  340. parsed_link = _parse_hyperlink_open(segment)
  341. if parsed_link is not None and parsed_link.url: # has URL = open
  342. state = parsed_link
  343. elif segment.startswith(('\x1b]8;;\x1b\\', '\x1b]8;;\x07')): # close
  344. state = None
  345. return state
  346. def _handle_long_word(self, reversed_chunks: list[str],
  347. cur_line: list[str], cur_len: int,
  348. width: int) -> None:
  349. """
  350. Sequence-aware :meth:`textwrap.TextWrapper._handle_long_word`.
  351. This method ensures that word boundaries are not broken mid-sequence, and respects grapheme
  352. cluster boundaries when breaking long words.
  353. """
  354. if width < 1:
  355. space_left = 1
  356. else:
  357. space_left = width - cur_len
  358. chunk = reversed_chunks[-1]
  359. if self.break_long_words:
  360. break_at_hyphen = False
  361. hyphen_end = 0
  362. # Handle break_on_hyphens: find last hyphen within space_left
  363. if self.break_on_hyphens:
  364. # Strip sequences to find hyphen in logical text
  365. stripped = self._strip_sequences(chunk)
  366. if len(stripped) > space_left:
  367. # Find last hyphen in the portion that fits
  368. hyphen_pos = stripped.rfind('-', 0, space_left)
  369. if hyphen_pos > 0 and any(c != '-' for c in stripped[:hyphen_pos]):
  370. # Map back to original position including sequences
  371. hyphen_end = self._map_stripped_pos_to_original(chunk, hyphen_pos + 1)
  372. break_at_hyphen = True
  373. # Break at grapheme boundaries to avoid splitting multi-codepoint characters
  374. if break_at_hyphen:
  375. actual_end = hyphen_end
  376. else:
  377. actual_end = self._find_break_position(chunk, space_left)
  378. # If no progress possible (e.g., wide char exceeds line width),
  379. # force at least one grapheme to avoid infinite loop.
  380. # Only force when cur_line is empty; if line has content,
  381. # appending nothing is safe and the line will be committed.
  382. if actual_end == 0 and not cur_line:
  383. actual_end = self._find_first_grapheme_end(chunk)
  384. cur_line.append(chunk[:actual_end])
  385. reversed_chunks[-1] = chunk[actual_end:]
  386. elif not cur_line:
  387. cur_line.append(reversed_chunks.pop())
  388. def _map_stripped_pos_to_original(self, text: str, stripped_pos: int) -> int:
  389. """Map a position in stripped text back to original text position."""
  390. stripped_idx = 0
  391. original_idx = 0
  392. for segment, is_seq in iter_sequences(text):
  393. if is_seq:
  394. original_idx += len(segment)
  395. elif stripped_idx + len(segment) > stripped_pos:
  396. # Position is within this segment
  397. return original_idx + (stripped_pos - stripped_idx)
  398. else:
  399. stripped_idx += len(segment)
  400. original_idx += len(segment)
  401. # Caller guarantees stripped_pos < total stripped chars, so we always
  402. # return from within the loop. This line satisfies the type checker.
  403. return original_idx # pragma: no cover
  404. def _find_break_position(self, text: str, max_width: int) -> int:
  405. """Find string index in text that fits within max_width cells."""
  406. idx = 0
  407. width_so_far = 0
  408. while idx < len(text):
  409. char = text[idx]
  410. # Skip escape sequences (they don't add width)
  411. if char == '\x1b':
  412. match = ZERO_WIDTH_PATTERN.match(text, idx)
  413. if match:
  414. idx = match.end()
  415. continue
  416. # Get grapheme (use start= to avoid slice allocation)
  417. grapheme = next(iter_graphemes(text, start=idx))
  418. grapheme_width = self._width(grapheme)
  419. if width_so_far + grapheme_width > max_width:
  420. return idx # Found break point
  421. width_so_far += grapheme_width
  422. idx += len(grapheme)
  423. # Caller guarantees chunk_width > max_width, so a grapheme always
  424. # exceeds and we return from within the loop. Type checker requires this.
  425. return idx # pragma: no cover
  426. def _find_first_grapheme_end(self, text: str) -> int:
  427. """Find the end position of the first grapheme."""
  428. return len(next(iter_graphemes(text)))
  429. def _rstrip_visible(self, text: str) -> str:
  430. """Strip trailing visible whitespace, preserving trailing sequences."""
  431. segments = list(iter_sequences(text))
  432. last_vis = -1
  433. for i, (segment, is_seq) in enumerate(segments):
  434. if not is_seq and segment.rstrip():
  435. last_vis = i
  436. if last_vis == -1:
  437. return ''
  438. result = []
  439. for i, (segment, is_seq) in enumerate(segments):
  440. if i < last_vis:
  441. result.append(segment)
  442. elif i == last_vis:
  443. result.append(segment.rstrip())
  444. elif is_seq:
  445. result.append(segment)
  446. return ''.join(result)
  447. def wrap(text: str, width: int = 70, *,
  448. control_codes: Literal['parse', 'strict', 'ignore'] = 'parse',
  449. tabsize: int = 8,
  450. expand_tabs: bool = True,
  451. replace_whitespace: bool = True,
  452. ambiguous_width: int = 1,
  453. initial_indent: str = '',
  454. subsequent_indent: str = '',
  455. fix_sentence_endings: bool = False,
  456. break_long_words: bool = True,
  457. break_on_hyphens: bool = True,
  458. drop_whitespace: bool = True,
  459. max_lines: int | None = None,
  460. placeholder: str = ' [...]',
  461. propagate_sgr: bool = True) -> list[str]:
  462. r"""
  463. Wrap text to fit within given width, returning a list of wrapped lines.
  464. Like :func:`textwrap.wrap`, but measures width in display cells rather than
  465. characters, correctly handling wide characters, combining marks, and terminal
  466. escape sequences.
  467. :param text: Text to wrap, may contain terminal sequences.
  468. :param width: Maximum line width in display cells.
  469. :param control_codes: How to handle terminal sequences (see :func:`~.width`).
  470. :param tabsize: Tab stop width for tab expansion.
  471. :param expand_tabs: If True (default), tab characters are expanded
  472. to spaces using ``tabsize``.
  473. :param replace_whitespace: If True (default), each whitespace character
  474. is replaced with a single space after tab expansion. When False,
  475. control whitespace like ``\n`` has zero display width (unlike
  476. :func:`textwrap.wrap` which counts ``len()``), so wrap points
  477. may differ from stdlib for non-space whitespace characters.
  478. :param ambiguous_width: Width to use for East Asian Ambiguous (A)
  479. characters. Default is ``1`` (narrow). Set to ``2`` for CJK contexts.
  480. :param initial_indent: String prepended to first line.
  481. :param subsequent_indent: String prepended to subsequent lines.
  482. :param fix_sentence_endings: If True, ensure sentences are always
  483. separated by exactly two spaces.
  484. :param break_long_words: If True, break words longer than width.
  485. :param break_on_hyphens: If True, allow breaking at hyphens.
  486. :param drop_whitespace: If True (default), whitespace at the beginning
  487. and end of each line (after wrapping but before indenting) is dropped.
  488. Set to False to preserve whitespace.
  489. :param max_lines: If set, output contains at most this many lines, with
  490. ``placeholder`` appended to the last line if the text was truncated.
  491. :param placeholder: String appended to the last line when text is
  492. truncated by ``max_lines``. Default is ``' [...]'``.
  493. :param propagate_sgr: If True (default), SGR (terminal styling) sequences
  494. are propagated across wrapped lines. Each line ends with a reset
  495. sequence and the next line begins with the active style restored.
  496. :returns: List of wrapped lines without trailing newlines.
  497. SGR (terminal styling) sequences are propagated across wrapped lines
  498. by default. Each line ends with a reset sequence and the next line
  499. begins with the active style restored::
  500. >>> wrap('\x1b[1;34mHello world\x1b[0m', width=6)
  501. ['\x1b[1;34mHello\x1b[0m', '\x1b[1;34mworld\x1b[0m']
  502. Set ``propagate_sgr=False`` to disable this behavior.
  503. Like :func:`textwrap.wrap`, newlines in the input text are treated as
  504. whitespace and collapsed. To preserve paragraph breaks, wrap each
  505. paragraph separately::
  506. >>> text = 'First line.\nSecond line.'
  507. >>> wrap(text, 40) # newline collapsed to space
  508. ['First line. Second line.']
  509. >>> [line for para in text.split('\n')
  510. ... for line in (wrap(para, 40) if para else [''])]
  511. ['First line.', 'Second line.']
  512. .. seealso::
  513. :func:`textwrap.wrap`, :class:`textwrap.TextWrapper`
  514. Standard library text wrapping (character-based).
  515. :class:`.SequenceTextWrapper`
  516. Class interface for advanced wrapping options.
  517. .. versionadded:: 0.3.0
  518. .. versionchanged:: 0.5.0
  519. Added ``propagate_sgr`` parameter (default True).
  520. .. versionchanged:: 0.6.0
  521. Added ``expand_tabs``, ``replace_whitespace``, ``fix_sentence_endings``,
  522. ``drop_whitespace``, ``max_lines``, and ``placeholder`` parameters.
  523. Example::
  524. >>> from wcwidth import wrap
  525. >>> wrap('hello world', 5)
  526. ['hello', 'world']
  527. >>> wrap('中文字符', 4) # CJK characters (2 cells each)
  528. ['中文', '字符']
  529. """
  530. # pylint: disable=too-many-arguments,too-many-locals
  531. wrapper = SequenceTextWrapper(
  532. width=width,
  533. control_codes=control_codes,
  534. tabsize=tabsize,
  535. expand_tabs=expand_tabs,
  536. replace_whitespace=replace_whitespace,
  537. ambiguous_width=ambiguous_width,
  538. initial_indent=initial_indent,
  539. subsequent_indent=subsequent_indent,
  540. fix_sentence_endings=fix_sentence_endings,
  541. break_long_words=break_long_words,
  542. break_on_hyphens=break_on_hyphens,
  543. drop_whitespace=drop_whitespace,
  544. max_lines=max_lines,
  545. placeholder=placeholder,
  546. )
  547. lines = wrapper.wrap(text)
  548. if propagate_sgr:
  549. lines = _propagate_sgr(lines)
  550. return lines