Obtenga el ancho de visualización de una cadena de caracteres

15

¿Cuál sería la forma más cercana a una forma portátil de obtener el ancho de visualización (al menos en un terminal (uno que muestre caracteres en el entorno local actual con el ancho correcto)) de una cadena de caracteres de un script de shell.

Estoy interesado principalmente en el ancho de los caracteres que no son de control, pero también son bienvenidas las soluciones que tienen en cuenta los caracteres de control como retroceso, retorno de carro y tabulación horizontal.

En otras palabras, estoy buscando una API de shell alrededor de la wcswidth()función POSIX.

Ese comando debería devolver:

$ 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

Uno podría usar ksh93's printf '%<n>Ls'que tenga en cuenta el ancho de caracteres para el relleno de las <n>columnas, o el colcomando (con, por ejemplo printf '++%s\b\b--\n' <character> | col -b) para tratar de derivar eso, hay un perlmódulo Text :: CharWidth al menos, pero hay enfoques más directos o portátiles.

Eso es más o menos un seguimiento de esa otra pregunta que era sobre mostrar texto a la derecha de la pantalla para la que necesitaría tener esa información antes de mostrar el texto.

Stéphane Chazelas
fuente

Respuestas:

7

En un emulador de terminal, uno podría usar el informe de posición del cursor para obtener posiciones antes / después, por ejemplo, de

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

y encuentre qué tan anchos están los caracteres impresos en la terminal. Como se trata de una secuencia de control ECMA-48 (así como VT100) compatible con casi cualquier terminal que pueda utilizar, es bastante portátil.

Para referencia

    CSI Ps n Informe de estado del dispositivo (DSR).
              ...
                Ps = 6 -> Informe de posición del cursor (CPR) [fila; columna].
              El resultado es CSI r; c R

Finalmente, el emulador de terminal determina el ancho imprimible, debido a estos factores:

  • la configuración regional afecta la forma en que se puede formatear una cadena, pero la serie de bytes enviados al terminal se interpreta en función de cómo está configurado el terminal (señalando que algunas personas argumentarán que tiene que ser UTF-8, mientras que, por otro lado portabilidad fue la característica solicitada en la pregunta).
  • wcswidthsolo no dice cómo se manejan los caracteres combinados; POSIX no menciona este aspecto en la descripción de esa función.
  • algunos caracteres (dibujo de líneas, por ejemplo) que uno podría dar por sentado como ancho simple son (en Unicode) "ancho ambiguo", lo que socava la portabilidad de una aplicación usando wcswidthsolo (consulte, por ejemplo, el Capítulo 2. Configuración de Cygwin ). xtermPor ejemplo, tiene la posibilidad de seleccionar caracteres de doble ancho para las configuraciones que se necesitan.
  • para manejar cualquier cosa que no sean caracteres imprimibles, tendría que confiar en el emulador de terminal (a menos que quiera simular eso).

Las llamadas a las API de Shell wcswidthson compatibles en diversos grados:

Esos son más o menos directos: simulando wcswidthen el caso de Perl, llamando al tiempo de ejecución C desde Ruby y Python. Incluso podría usar maldiciones, por ejemplo, de Python (que manejaría la combinación de caracteres):

  • inicialice el terminal usando setupterm (no se escribe texto en la pantalla)
  • use la filterfunción (para líneas simples)
  • dibuje el texto al comienzo de la línea con addstr, verificando el error (en caso de que sea demasiado largo), y luego para la posición final
  • Si hay espacio, ajuste la posición inicial.
  • llamada endwin(que no debe hacer a refresh)
  • escriba la información resultante sobre la posición inicial en la salida estándar

El uso de maldiciones para la salida (en lugar de enviar la información a un script o llamar directamente tput) borraría toda la línea ( filterlo limita a una línea).

Thomas Dickey
fuente
Creo que esta debe ser la única forma, de verdad. si el terminal no admite caracteres de doble ancho, entonces no importa mucho lo que wcswidth()tenga que decir sobre nada.
mikeserv
En la práctica, el único problema que he tenido con este método es plinkque se establece TERM=xterma pesar de que no responde a ninguna secuencia de control. Pero no uso terminales muy exóticos.
Gilles 'SO- deja de ser malvado'
Gracias. pero la idea era obtener esa información antes de mostrar la cadena en el terminal (para saber dónde mostrarla, eso es un seguimiento de la pregunta reciente sobre cómo mostrar una cadena a la derecha del terminal, tal vez debería haber mencionado que aunque mi pregunta real era realmente acerca de cómo llegar a wcswidth desde el shell). @mikeserv, sí wcswidth () puede estar equivocado acerca de cómo un terminal específico mostrará una cadena en particular, pero eso es lo más cerca que puede llegar a una solución independiente del terminal y eso es lo que usa col / ksh-printf en mi sistema.
Stéphane Chazelas
Soy consciente de eso, pero no se puede acceder directamente a wcswidth, excepto a través de características menos portátiles (puede hacer esto en perl, haciendo algunas suposiciones; consulte search.cpan.org/dist/Text-CharWidth/CharWidth.pm ) . Por cierto, la pregunta de alineación a la derecha podría mejorarse (tal vez) escribiendo la cadena en la esquina inferior izquierda y luego usando la posición del cursor y los controles de inserción para desplazarla a la esquina inferior derecha.
Thomas Dickey
1
@ StéphaneChazelas: foldaparentemente está especificado para manejar caracteres de varios bytes y ancho extendido . Así es como debería manejar el retroceso: el conteo actual del ancho de línea se reducirá en uno, aunque el conteo nunca será negativo. La utilidad de plegado no debe insertar una <nueva línea> inmediatamente antes o después de cualquier <retroceso>, a menos que el siguiente carácter tenga un ancho mayor que 1 y pueda causar que el ancho de la línea exceda el ancho. tal vez fold -w[num]y pr +[num]podría ser unida de alguna manera?
mikeserv
5

Para cadenas de una línea, la implementación de GNU wctiene una opción -L(aka --max-line-length) que hace exactamente lo que está buscando (excepto los caracteres de control).

egmont
fuente
1
Gracias. No tenía idea de que devolvería el ancho de la pantalla. Tenga en cuenta que la implementación de FreeBSD también tiene una opción -L, el documento dice que devuelve el número de caracteres en la línea más larga, pero mi prueba parece indicar que es un número de bytes (no el ancho de pantalla en ningún caso). OS / X no tiene -L aunque hubiera esperado que derivara de FreeBSD.
Stéphane Chazelas
Parece que también se maneja tab(se supone que la tabulación se detiene cada 8 columnas).
Stéphane Chazelas
En realidad, para cadenas de más de una línea, diría que también hace exactamente lo que estoy buscando, ya que maneja los caracteres de control LF correctamente .
Stéphane Chazelas
@ StéphaneChazelas: ¿Sigue teniendo el problema de que esto devuelve el número de bytes en lugar del número de caracteres? Lo probé en sus datos y obtuve los resultados que deseaba: wc -L <<< 'unix'→ 8,  wc -L <<< 'Stéphane'→ 8 y  wc -L <<< 'もで 諤奯ゞ'→ 11. PD ¿Considera que "Stéphane" tiene nueve caracteres, uno de los cuales es de ancho cero? Me parece que hay ocho caracteres, uno de los cuales es de varios bytes.
G-Man dice 'Restablecer a Monica' el
@ G-Man, me refería a la implementación de FreeBSD, que en FreeBSD 12.0 y un entorno local UTF-8 todavía parece contar bytes. Tenga en cuenta que é puede escribirse usando un carácter U + 00E9 o un carácter U + 0065 (e) seguido de U + 0301 (combinando acento agudo), siendo este último el que se muestra en la pregunta.
Stéphane Chazelas
4

En mi .profile, llamo un script para determinar el ancho de una cadena en un terminal. Lo uso cuando inicio sesión en la consola de una máquina en la que no confío en el conjunto del sistema LC_CTYPE, o cuando inicio sesión de forma remota y no puedo confiar en LC_CTYPEque coincida con el lado remoto. Mi script consulta el terminal, en lugar de llamar a cualquier biblioteca, porque ese era el punto principal en mi caso de uso: determinar la codificación del terminal.

Esto es frágil de varias maneras:

  • modifica la pantalla, por lo que no es una experiencia de usuario muy agradable;
  • hay una condición de carrera si otro programa muestra algo en el momento equivocado;
  • se bloquea si el terminal no responde. (Hace unos años, pregunté cómo mejorar esto , pero no ha sido un gran problema en la práctica, así que nunca pude cambiar a esa solución. El único caso que encontré de un terminal que no responde fue un Windows Emacs accediendo a archivos remotos desde una máquina Linux con el plinkmétodo, y lo resolví usando el plinkxmétodo en su lugar ).

Esto puede o no coincidir con su 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

El script devuelve el ancho en su estado de retorno, recortado a 100. Uso de muestra:

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- deja de ser malvado'
fuente
Esto fue útil para mí (aunque usé principalmente su versión condensada ). Hice su uso un poco más bonito al agregar printf "\r%*s\r" $((${#text}+8)) " ";al final de cleanup(agregar 8 es arbitrario; debe ser lo suficientemente largo para cubrir la salida más amplia de las configuraciones regionales más antiguas pero lo suficientemente estrecho para evitar un ajuste de línea). Esto hace que la prueba sea invisible, aunque también supone que no se ha impreso nada en la línea (lo cual está bien en una ~/.profile)
Adam Katz
En realidad, parece que, mediante una pequeña experimentación, en zsh (5.7.1) puede simplemente hacer text="Éé"y luego ${#text}le dará el ancho de la pantalla (obtengo 4en un terminal no unicode y 2en un terminal compatible con unicode). Esto no es cierto para bash.
Adam Katz
@AdamKatz ${#text}no te da el ancho de la pantalla. Le da el número de caracteres en la codificación utilizada por la configuración regional actual. Lo cual es inútil para mi propósito, ya que quiero determinar la codificación del terminal. Es útil si desea el ancho de la pantalla por algún otro motivo, pero no es preciso porque no todos los caracteres tienen una unidad de ancho. Por ejemplo, la combinación de acentos tiene un ancho de 0, y los ideogramas chinos tienen un ancho de 2.
Gilles 'SO- deja de ser malvado'
Si, buen punto. Puede satisfacer la pregunta de Stéphane, pero no su intención original (que en realidad es lo que quería hacer también, por lo tanto, adaptar mi código). Espero que mi primer comentario haya sido útil para usted, Gilles.
Adam Katz
3

Eric Pruitt escribió una implementación impresionante de wcwidth()ywcswidth() en Awk disponible en wcwidth.awk . Proporciona principalmente 4 funciones.

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

donde wcscolumns()también tolera caracteres no imprimibles.

$ 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

Abrí un problema preguntando sobre el manejo de TAB, ya que wcscolumns($'My sign is\t鼠鼠')debería ser mayor que 14. Actualización: Eric agregó la función wcsexpand()para expandir TAB a espacios:

$ 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
fuente
1

Para ampliar las sugerencias sobre posibles soluciones usando coly ksh93en mi pregunta:

Usando el colde bsdmainutilsDebian (puede que no funcione con otras colimplementaciones), para obtener el ancho de un solo carácter sin control:

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

Ejemplo:

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

Extendido para una cadena:

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
fuente