Obter a largura de exibição de uma sequência de caracteres

15

Qual seria a maneira mais próxima de uma maneira portátil de obter a largura de exibição (pelo menos em um terminal (um que exibe caracteres no local atual com a largura correta)) de uma sequência de caracteres de um script de shell.

Estou interessado principalmente na largura de caracteres que não são de controle, mas as soluções que levam em conta caracteres de controle como backspace, retorno de carro e tabulação horizontal também são bem-vindas.

Em outras palavras, estou procurando uma API de shell em torno dowcswidth() função POSIX.

Esse comando deve retornar:

$ 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

Pode-se usar ksh93's' printf '%<n>Ls'que levam em consideração a largura dos caracteres para preenchimento de <n>colunas, ou o colcomando (com por exemplo printf '++%s\b\b--\n' <character> | col -b) para tentar derivar isso, existe um Text :: CharWidthperl pelo menos um módulo , mas existem abordagens mais diretas ou portáteis.

Isso é mais ou menos um acompanhamento dessa outra pergunta que era sobre a exibição de texto à direita da tela, para a qual você precisaria ter essas informações antes de exibir o texto.

Stéphane Chazelas
fonte

Respostas:

7

Em um emulador de terminal, pode-se usar o relatório de posição do cursor para obter posições antes / depois, por exemplo, de

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

e descubra a largura dos caracteres impressos no terminal. Como é uma sequência de controle ECMA-48 (assim como VT100) suportada por quase todos os terminais que você provavelmente usa, é bastante portátil.

Para referência

    CSI Ps n Relatório de status do dispositivo (DSR).
              ...
                Ps = 6 -> Posição do Cursor do Relatório (CPR) [linha; coluna].
              O resultado é CSI r; c R

Por fim, o emulador de terminal determina a largura imprimível, devido a esses fatores:

  • as configurações do código do idioma afetam a forma como uma sequência pode ser formatada, mas as séries de bytes enviadas ao terminal são interpretadas com base na configuração do terminal (observando que algumas pessoas argumentam que ele deve ser UTF-8, enquanto por outro lado portabilidade foi o recurso solicitado na pergunta).
  • wcswidthsozinho não diz como os caracteres combinados são tratados; O POSIX não menciona esse aspecto na descrição dessa função.
  • alguns caracteres (desenho de linha, por exemplo) que podem ser considerados como largura única são (em Unicode) "largura ambígua", prejudicando a portabilidade de um aplicativo usando wcswidthsozinho (consulte, por exemplo, o Capítulo 2. Configurando o Cygwin ). xtermpor exemplo, tem provisão para a seleção de caracteres de largura dupla para as configurações necessárias.
  • para manipular qualquer coisa que não seja caracteres imprimíveis, você precisaria confiar no emulador de terminal (a menos que queira simular isso).

As chamadas de APIs do Shell wcswidthsão suportadas em vários graus:

Essas são mais ou menos diretas: simulando wcswidthno caso do Perl, chamando o tempo de execução C do Ruby e Python. Você pode até usar maldições, por exemplo, do Python (que trataria da combinação de caracteres):

  • inicialize o terminal usando setupterm (nenhum texto é gravado na tela)
  • use a filterfunção (para linhas únicas)
  • desenhe o texto no início da linha com addstr, verificando se há erro (caso seja muito longo) e depois para a posição final
  • se houver espaço, ajuste a posição inicial.
  • chamada endwin(que não deve fazer a refresh)
  • escreva as informações resultantes sobre a posição inicial na saída padrão

Usar maldições para a saída (em vez de alimentar as informações de volta para um script ou chamar diretamente tput) limparia a linha inteira ( filterlimita-a a uma linha).

Thomas Dickey
fonte
Eu acho que esse deve ser o único caminho, realmente. se o terminal não suporta caracteres de largura dupla, não importa muito o wcswidth()que dizer sobre algo.
mikeserv
Na prática, o único problema que tive com esse método é o plinkque é definido, TERM=xtermmesmo que não responda a nenhuma sequência de controle. Mas eu não uso terminais muito exóticos.
Gilles 'SO- stop be evil'
Obrigado. mas a ideia era obter essas informações antes de exibir a sequência no terminal (para saber onde exibi-la, é um acompanhamento da pergunta recente sobre a exibição de uma sequência à direita do terminal, talvez eu devesse ter mencionado que embora minha pergunta real fosse realmente sobre como obter a largura de banda do shell). @mikeserv, yes wcswidth () pode estar errado sobre como um terminal específico exibirá uma sequência específica, mas é o mais próximo possível de uma solução independente de terminal e é isso que col / ksh-printf usa no meu sistema.
Stéphane Chazelas
Estou ciente disso, mas o wcswidth não é acessível diretamente, exceto por recursos menos portáteis (você pode fazer isso em perl, fazendo algumas suposições - consulte search.cpan.org/dist/Text-CharWidth/CharWidth.pm ) . A questão do alinhamento à direita, a propósito, poderia ser (talvez) melhorada, escrevendo a string na parte inferior esquerda e, em seguida, usando a posição do cursor e os controles de inserção para deslocá-la para a parte inferior direita.
Thomas Dickey
1
@ StéphaneChazelas - foldaparentemente é específico para lidar com caracteres de vários bytes e largura estendida . Eis como deve lidar com o backspace: A contagem atual da largura da linha deve ser diminuída em um, embora a contagem nunca se torne negativa. O utilitário de dobra não deve inserir um <newline> imediatamente antes ou depois de qualquer <backspace>, a menos que o caractere a seguir tenha uma largura maior que 1 e faça com que a largura da linha exceda a largura. talvez fold -w[num]e pr +[num]poderia ser unido de alguma forma?
mikeserv
5

Para strings de uma linha, a implementação GNU de wctem uma opção -L(aka --max-line-length) que faz exatamente o que você está procurando (exceto os caracteres de controle).

Egmont
fonte
1
Obrigado. Eu não tinha ideia de que retornaria a largura da tela. Observe que a implementação do FreeBSD também possui uma opção -L, o documento diz que retorna o número de caracteres na linha mais longa, mas meu teste parece indicar que há um número de bytes (não a largura de exibição em nenhum caso). O OS / X não possui -L, embora eu esperasse que ele derivasse do FreeBSD.
Stéphane Chazelas
Parece lidar tabtambém (assume que a tabulação é interrompida a cada 8 colunas).
Stéphane Chazelas
Na verdade, para seqüências de mais de uma linha, eu diria que também faz exatamente o que estou procurando, pois lida com os caracteres de controle LF corretamente .
Stéphane Chazelas
@ StéphaneChazelas: Você ainda está com o problema de que isso retorna o número de bytes em vez do número de caracteres? Eu testei com seus dados e obtive os resultados desejados: wc -L <<< 'unix'→ 8,  wc -L <<< 'Stéphane'→ 8 e  wc -L <<< 'もで 諤奯ゞ'→ 11. PS Você considera "Stéphane" com nove caracteres, um dos quais com largura zero? Parece-me oito caracteres, um dos quais é de vários bytes.
G-Man diz 'Reinstate Monica'
@ G-Man, eu estava me referindo à implementação do FreeBSD, que no FreeBSD 12.0 e na localidade UTF-8 ainda parece contar bytes. Observe que é pode ser escrito usando um caractere U + 00E9 ou um caractere U + 0065 (e) seguido de U + 0301 (combinando acento agudo), sendo este último o mostrado na pergunta.
Stéphane Chazelas
4

No meu .profile, eu chamo um script para determinar a largura de uma string em um terminal. Uso isso ao efetuar login no console de uma máquina em que não confio no conjunto do sistema LC_CTYPEou quando faço logon remotamente e não posso confiar LC_CTYPEpara corresponder ao lado remoto. Meu script consulta o terminal, em vez de chamar qualquer biblioteca, porque esse era o ponto principal do meu caso de uso: determinar a codificação do terminal.

Isso é frágil de várias maneiras:

  • modifica a exibição, portanto não é uma experiência muito agradável para o usuário;
  • existe uma condição de corrida se outro programa exibir algo na hora errada;
  • ele trava se o terminal não responder. (Há alguns anos, perguntei como melhorar isso , mas na prática não foi um problema muito grande, então nunca mudei para essa solução. O único caso que encontrei de um terminal que não responde foi um Windows Emacs acessando arquivos remotos de uma máquina Linux com o plinkmétodo, e eu o resolvi usando o plinkxmétodo .)

Isso pode ou não corresponder ao seu caso de uso.

#! /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

O script retorna a largura em seu status de retorno, cortada para 100. Uso de amostra:

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
Gilles 'SO- parar de ser mau'
fonte
Isso foi útil para mim (embora eu tenha usado principalmente a sua versão condensada ). Tornei seu uso um pouco mais bonito adicionando printf "\r%*s\r" $((${#text}+8)) " ";ao final de cleanup(adicionar 8 é arbitrário; ele precisa ser longo o suficiente para cobrir a saída mais ampla de locais mais antigos, mas estreito o suficiente para evitar uma quebra de linha). Isso faz com que o teste invisível, embora também assume nada foi impresso na linha (que é bom em um ~/.profile)
Adam Katz
Na verdade, parece que, a partir de uma pequena experiência, no zsh (5.7.1) você pode fazer text="Éé"e ${#text}fornecer a largura de exibição (eu entro 4em um terminal não unicode e 2em um terminal compatível com unicode). Isso não é verdade para o bash.
Adam Katz
O @AdamKatz ${#text}não oferece a largura de exibição. Fornece o número de caracteres na codificação usada pelo código de idioma atual. O que é inútil para o meu propósito, pois quero determinar a codificação do terminal. É útil se você deseja a largura da tela por algum outro motivo, mas não é preciso porque nem todos os caracteres têm uma unidade de largura. Por exemplo, combinar acentos tem uma largura de 0 e ideogramas chineses têm uma largura de 2.
Gilles 'pára de ser mau'
Sim, bom argumento. Pode satisfazer a pergunta de Stéphane, mas não a sua intenção original (que é realmente o que eu também queria fazer, portanto adaptando seu código). Espero que meu primeiro comentário tenha sido útil para você, Gilles.
Adam Katz
3

Eric Pruitt escreveu uma implementação impressionante de wcwidth()e wcswidth()no Awk disponível em wcwidth.awk . Fornece principalmente 4 funções

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

onde wcscolumns()também tolera caracteres não imprimíveis.

$ 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

Eu abri um problema perguntando sobre o manuseio de TABs, pois wcscolumns($'My sign is\t鼠鼠')deveria ser maior que 14. Atualização: Eric adicionou a função wcsexpand()para expandir os TABs aos espaços:

$ 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
xebeche
fonte
1

Para expandir as dicas de possíveis soluções usando cole ksh93na minha pergunta:

Usando o colfrom bsdmainutilsno Debian (pode não funcionar com outras colimplementações), para obter a largura de um único caractere não-controle:

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

Exemplo:

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

Estendido para uma sequência:

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"
}

Usando ksh93's printf '%Ls':

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

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

Usando perl's Text::CharWidth:

stringwidth() {
  perl -MText::CharWidth=mbswidth -le 'print mbswidth shift' "$@"
}
Stéphane Chazelas
fonte