获取字符串显示宽度


15

从外壳脚本获取一串字符的显示宽度(至少在一个终端上(至少一个以正确的宽度显示当前语言环境中的字符的显示终端))的最接近可移植的方式。

我主要对非控制字符的宽度感兴趣,但是也欢迎考虑诸如退格键,回车符,水平制表符之类的控制字符的解决方案。

换句话说,我正在寻找围绕POSIX函数的Shell API wcswidth()

该命令应返回:

$ that-command 'unix'   # 4 fullwidth characters
8
$ that-command 'Stéphane' # 9 characters, one of which zero-width
8
$ that-command 'もで 諤奯ゞ' # 5 double-width Japanese characters and a space
11

人们可以使用ksh93printf '%<n>Ls',考虑到用于填充字符宽度<n>列,或col命令(例如printf '++%s\b\b--\n' <character> | col -b),试图推导出,有一个文本:: CharWidth perl模块至少,但是否有更直接的或便携式的方法。

这或多或少是关于另一个问题的后续问题该问题是关于在屏幕右侧显示文本,在显示文本之前您需要具有该信息。


Answers:


7

在终端仿真器中,可以使用光标位置报告来获取之前/之后的位置,例如,从

...record position
printf '%s' $string
...record position

并找出在终端上打印的字符宽度。由于这是您可能使用的几乎所有终端都支持的ECMA-48(以及VT100)控制序列,因此它相当轻便。

以供参考

    CSI Ps n设备状态报告(DSR)。
              ...
                Ps = 6->报告光标位置(CPR)[行;列]。
              结果是CSI r; R

最终,由于以下因素,终端仿真器确定了可打印宽度:

  • 区域设置会影响字符串格式化的方式,但是发送到终端的一系列字节将根据终端的配置方式进行解释(注意有些人会认为它必须为UTF-8,而另一方面可移植性是问题中要求的功能)。
  • wcswidth单单并不能说明如何处理组合字符。POSIX在该功能的描述中未提及此方面。
  • 有些字符(例如,画线)可能被认为是单宽度的(在Unicode中)是“歧义宽度”,这损害了wcswidth单独使用应用程序的可移植性(例如,参见第2章。设置Cygwin)。 xterm例如,可以为需要的配置选择全角字符。
  • 要处理除可打印字符以外的任何内容,您将不得不依赖终端仿真器(除非您要模拟它)。

Shell API调用wcswidth在不同程度上受到支持:

这些或多或少是直接的:wcswidth在Perl的情况下进行仿真,从Ruby和Python调用C运行时。您甚至可以使用curses,例如来自Python的curses(它将处理组合字符):

  • 使用setupterm初始化终端(没有文本写入屏幕)
  • 使用filter功能(单行)
  • 使用,在行的开头绘制文本addstr,检查是否有错误(如果过长),然后检查结束位置
  • 如果有空间,请调整起始位置。
  • 致电endwin(不应这样做refresh
  • 将有关起始位置的结果信息写入标准输出

使用curses 输出(而不是将信息反馈给脚本或直接调用tput)会清除整行(filter确实将其限制为一行)。


我认为这确实是唯一的方法。如果终端不支持全角字符,那么根本不用wcswidth()说什么都没关系。
mikeserv

在实践中,我对这种方法唯一的问题是plinkTERM=xterm即使它不响应任何控制序列,它也会进行设置。但是我不使用非常特殊的终端。
吉尔斯(Gilles)'所以

谢谢。但是想法是在终端上显示字符串之前先获取该信息(要知道在哪里显示它,这是对最近一个有关在终端右侧显示字符串的问题的跟进,也许我应该提到尽管我真正的问题是关于如何从shell进入wcswidth)。@mikeserv,是的,关于特定终端如何显示特定字符​​串,wcswidth()可能是错误的,但这与独立于终端的解决方案非常接近,这就是我的系统上使用的col / ksh-printf。
斯特凡Chazelas

我知道这一点,但是wcswidth不能通过不可移植的功能直接访问(您可以通过做一些假设在perl中做到这一点-参见search.cpan.org/dist/Text-CharWidth/CharWidth.pm) 。顺便说一句,可以(也许)通过将字符串写到左下角,然后使用光标位置和插入控件将其移到右下角来改善右对齐问题。
Thomas Dickey

1
@StéphaneChazelas- fold显然已指定用于处理多字节和扩展宽度的字符。它应如何处理退格:当前的线宽计数应减1,尽管该计数永远不会变为负数。fold实用程序不得在任何<backspace>之前或之后立即插入<newline>,除非以下字符的宽度大于1,并且会导致线宽超过宽度。也许fold -w[num]并且pr +[num]可以以某种方式进行合作?
mikeserv

5

对于单行字符串,GNU的实现wc具有-L(aka --max-line-length)选项,该选项完全可以满足您的要求(控制字符除外)。


1
谢谢。我不知道它会返回显示宽度。请注意,FreeBSD实现也具有-L选项,文档说它返回最长行中的字符数,但是我的测试似乎表明它是字节数(无论如何都不是显示宽度)。OS / X没有-L,即使我曾希望它来自FreeBSD。
斯特凡Chazelas

它似乎也可以处理tab(假设Tab键每8列停止一次)。
斯特凡Chazelas

实际上,对于多于一行的字符串,我会说它也正是我想要的,因为它可以正确处理LF控制字符。
斯特凡Chazelas

@StéphaneChazelas:您是否还有问题,这将返回字节数而不是字符数?我在您的数据上对其进行了测试,并获得了所需的结果:wc -L <<< 'unix'→8,  wc -L <<< 'Stéphane'→8和  wc -L <<< 'もで 諤奯ゞ'→11。PS您认为“Stéphane”是9个字符,其中一个是零宽度?在我看来,它看起来像八个字符,其中之一是多字节。
G-Man说'Resstate Monica''Jun

@ G-Man,我指的是FreeBSD实现,在FreeBSD 12.0和UTF-8语言环境中,该实现似乎仍在计算字节数。请注意,可以使用一个U + 00E9字符或一个U + 0065(e)字符,再加上U + 0301(结合重音)来书写é,后者是问题中显示的字符。
斯特凡Chazelas

4

在我的中.profile,我调用一个脚本来确定终端上字符串的宽度。在不信任系统集的计算机的控制台上LC_CTYPE登录时,或者在远程登录且无法信任LC_CTYPE与远程端匹配的机器上时,我会使用此功能。我的脚本查询终端,而不是调用任何库,因为这是我用例的全部要点:确定终端的编码。

这在几个方面都很脆弱:

  • 它会修改显示,因此不是很好的用户体验;
  • 如果另一个程序在错误的时间显示某些内容,则会出现争用情况;
  • 如果终端不响应,它将锁定。(几年前,我问如何对此进行改进,但实际上这并不是一个大问题,因此我从没转向过该解决方案。我遇到的唯一一个终端无响应的情况是Windows Emacs使用该plink方法从Linux机器访问远程文件,我改为使用plinkx方法解决了该问题。)

这可能与您的用例不符。

#! /bin/sh

if [ z"$ZSH_VERSION" = z ]; then :; else
  emulate sh 2>/dev/null
fi
set -e

help_and_exit () {
  cat <<EOF
Usage: $0 {-NUMBER|TEXT}
Find out the width of TEXT on the terminal.

LIMITATION: this program has been designed to work in an xterm. Only
xterm and sufficiently compatible terminals will work. If you think
this program may be blocked waiting for input from the the terminal,
try entering the characters "0n0n" (digit 0, lowercase letter n,
repeat).

Display TEXT and erase it. Find out the position of the cursor before
and after displaying TEXT so as to compute the width of TEXT. The width
is returned as the exit code of the program. A value of 100 is returned if
the text is wider than 100 columns.

TEXT may contain backslash-escapes: \\0DDD represents the byte whose numeric
value is DDD in octal. Use '\\\\' to include a single backslash character.

You may use -NUMBER instead of TEXT (if TEXT begins with a dash, use
"-- TEXT"). This selects one of the built-in texts that are designed
to discriminate between common encodings. The following table lists
supported values of NUMBER (leftmost column) and the widths of the
sample text in several encodings.

  1  ASCII=0 UTF-8=2 latinN=3 8bits=4
EOF
  exit
}

builtin_text () {
  case $1 in
    -*[!0-9]*)
      echo 1>&2 "$0: bad number: $1"
      exit 119;;
    -1) # UTF8: {\'E\'e}; latin1: {\~A\~A\copyright}; ASCII: {}
      text='\0303\0211\0303\0251';;
    *)
      echo 1>&2 "$0: there is no text number $1. Stop."
      exit 118;;
  esac
}

text=
if [ $# -eq 0 ]; then
  help_and_exit 1>&2
fi
case "$1" in
  --) shift;;
  -h|--help) help_and_exit;;
  -[0-9]) builtin_text "$1";;
  -*)
    echo 1>&2 "$0: unknown option: $1"
    exit 119
esac
if [ z"$text" = z ]; then
  text="$1"
fi

printf "" # test that it is there (abort on very old systems)

csi='\033['
dsr_cpr="${csi}6n" # Device Status Report --- Report Cursor Position
dsr_ok="${csi}5n" # Device Status Report --- Status Report

stty_save=`stty -g`
if [ z"$stty_save" = z ]; then
  echo 1>&2 "$0: \`stty -g' failed ($?)."
  exit 3
fi
initial_x=
final_x=
delta_x=

cleanup () {
  set +e
  # Restore terminal settings
  stty "$stty_save"
  # Restore cursor position (unless something unexpected happened)
  if [ z"$2" = z ]; then
    if [ z"$initial_report" = z ]; then :; else
      x=`expr "${initial_report}" : "\\(.*\\)0"`
      printf "%b" "${csi}${x}H"
    fi
  fi
  if [ z"$1" = z ]; then
    # cleanup was called explicitly, so don't exit.
    # We use `trap : 0' rather than `trap - 0' because the latter doesn't
    # work in older Bourne shells.
    trap : 0
    return
  fi
  exit $1
}
trap 'cleanup 120 no' 0
trap 'cleanup 129' 1
trap 'cleanup 130' 2
trap 'cleanup 131' 3
trap 'cleanup 143' 15

stty eol 0 eof n -echo
printf "%b" "$dsr_cpr$dsr_ok"
initial_report=`tr -dc \;0123456789`
# Get the initial cursor position. Time out if the terminal does not reply
# within 1 second. The trick of calling tr and sleep in a pipeline to put
# them in a process group, and using "kill 0" to kill the whole process
# group, was suggested by Stephane Gimenez at
# /unix/10698/timing-out-in-a-shell-script
#trap : 14
#set +e
#initial_report=`sh -c 'ps -t $(tty) -o pid,ppid,pgid,command >/tmp/p;
#                       { tr -dc \;0123456789 >&3; kill -14 0; } |
#                       { sleep 1; kill -14 0; }' 3>&1`
#set -e
#initial_report=`{ sleep 1; kill 0; } |
#                { tr -dc \;0123456789 </dev/tty; kill 0; }`
if [ z"$initial_report" = z"" ]; then
  # We couldn't read the initial cursor position, so abort.
  cleanup 120
fi
# Write some text and get the final cursor position.
printf "%b%b" "$text" "$dsr_cpr$dsr_ok"
final_report=`tr -dc \;0123456789`

initial_x=`expr "$initial_report" : "[0-9][0-9]*;\\([0-9][0-9]*\\)0" || test $? -eq 1`
final_x=`expr "$final_report" : "[0-9][0-9]*;\\([0-9][0-9]*\\)0" || test $? -eq 1`
delta_x=`expr "$final_x" - "$initial_x" || test $? -eq 1`

cleanup
# Zsh has function-local EXIT traps, even in sh emulation mode. This
# is a long-standing bug.
trap : 0

if [ $delta_x -gt 100 ]; then
  delta_x=100
fi
exit $delta_x

脚本以其返回状态返回宽度,裁剪为100。用法示例:

widthof -1
case $? in
  0) export LC_CTYPE=C;; # 7-bit charset
  2) locale_search .utf8 .UTF-8;; # utf8
  3) locale_search .iso88591 .ISO8859-1 .latin1 '';; # 8-bit with nonprintable 128-159, we assume latin1
  4) locale_search .iso88591 .ISO8859-1 .latin1 '';; # some full 8-bit charset, we assume latin1
  *) export LC_CTYPE=C;; # weird charset
esac

这对我很有帮助(尽管我主要使用的是精简版本)。我printf "\r%*s\r" $((${#text}+8)) " ";在末尾添加了一个漂亮的名称cleanup(添加8是任意的;它必须足够长以覆盖较旧语言环境的输出,但又要足够窄以避免行换行)。这使测试不可见,尽管它还假定行上没有任何内容打印出来(在中很好~/.profile
Adam Katz

实际上,通过一些试验,您似乎可以在zsh(5.7.1)中做到这一点text="Éé",然后${#text}为您提供显示宽度(我4在非unicode终端和2兼容unicode的终端中得到)。对于bash而言并非如此。
亚当·卡兹

@AdamKatz ${#text}没有给您显示宽度。它为您提供当前语言环境使用的编码中的字符数。这对我的目的没有用,因为我想确定终端的编码。如果由于其他原因想要显示宽度,这很有用,但是由于并非每个字符都一个单位宽,因此它并不准确。例如结合修饰具有0宽度,和中国表意文字具有为2的宽度
吉尔“SO-光阑恶”

是的,很好。它可能满足了Stéphane的问题,但不能满足您的初衷(实际上我也想这样做,因此我可以修改您的代码)。希望我的第一句话对您有帮助,吉尔斯。
亚当·卡兹

3

埃里克·普鲁特写了一个令人印象深刻的实施wcwidth(),并wcswidth()在提供awk中wcwidth.awk。它主要提供4个功能

wcscolumns(), wcstruncate(), wcwidth(), wcswidth()

其中wcscolumns()还容许非打印字符。

$ cat wcscolumns.awk 
{ printf "%d\n", wcscolumns($0) }
$ awk -f wcwidth.awk -f wcscolumns.awk <<< 'unix'
8
$ awk -f wcwidth.awk -f wcscolumns.awk <<< 'Stéphane'
8
$ awk -f wcwidth.awk -f wcscolumns.awk <<< 'もで 諤奯ゞ'
11
$ awk -f wcwidth.awk -f wcscolumns.awk <<< $'My sign is\t鼠鼠'
14

我打开了一个问题,询问TAB的处理,因为wcscolumns($'My sign is\t鼠鼠')应该大于14。更新: Eric添加了wcsexpand()将TAB扩展到空格的功能:

$ cat >wcsexpand.awk 
{ printf "%d\n", wcscolumns( wcsexpand($0, 8) ) }
$ awk -f wcwidth.awk -f wcsexpand.awk <<< $'My sign is\t鼠鼠'
20
$ echo $'鼠\tone\n鼠鼠\ttwo'
      one
鼠鼠    two
$ awk -f wcwidth.awk -f wcsexpand.awk <<< $'鼠\tone\n鼠鼠\ttwo'
11
11

1

为了扩展提示,使用colksh93我的问题:

在Debian上使用colfrom bsdmainutils(可能不适用于其他col实现)来获取单个非控制字符的宽度:

charwidth() {
  set "$(printf '...%s\b\b...\n' "$1" | col -b)"
  echo "$((${#1} - 4))"
}

例:

$ charwidth x
1
$ charwidth $'\u301'
0
$ charwidth $'\u94f6'
2

扩展为字符串:

stringwidth() {
   awk '
     BEGIN{
       s = ARGV[1]
       l = length(s)
       for (i=0; i<l; i++) {
         s1 = s1 ".."
         s2 = s2 "\b\b"
       }
       print s1 s s2 s1
       exit
     }' "$1" | col -b | awk '
        {print length - 2 * length(ARGV[2]); exit}' - "$1"
}

使用ksh93printf '%Ls'

charwidth() {
  set "$(printf '.%2Ls.' "$1")"
  echo "$((5 - ${#1}))"
}

stringwidth() {
  set "$(printf '.%*Ls.' "$((2*${#1}))" "$1")" "$1"
  echo "$((2 + 3 * ${#2} - ${#1}))"
}

使用perlText::CharWidth

stringwidth() {
  perl -MText::CharWidth=mbswidth -le 'print mbswidth shift' "$@"
}
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.