Obtenir la largeur d'affichage d'une chaîne de caractères

15

Quel serait le plus proche d'un moyen portable pour obtenir la largeur d'affichage (sur un terminal au moins (celui qui affiche les caractères dans les paramètres régionaux actuels avec la bonne largeur)) d'une chaîne de caractères à partir d'un script shell.

Je m'intéresse principalement à la largeur des caractères non contrôlables, mais les solutions qui prennent en compte les caractères de contrôle comme le retour arrière, le retour chariot, la tabulation horizontale sont également les bienvenues.

En d'autres termes, je recherche une API shell autour de la wcswidth()fonction POSIX.

Cette commande devrait retourner:

$ 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

On pourrait utiliser ksh93l » printf '%<n>Ls'qui prend en compte la largeur des caractères pour le rembourrage à <n>colonnes, ou la colcommande (avec , par exemple printf '++%s\b\b--\n' <character> | col -b) pour essayer de déduire que, il y a un texte :: charwidth le perlmodule au moins, mais sont là des approches plus directes ou portables.

C'est plus ou moins un suivi de cette autre question qui concernait l'affichage du texte à droite de l'écran pour lequel vous auriez besoin de ces informations avant d'afficher le texte.

Stéphane Chazelas
la source

Réponses:

7

Dans un émulateur de terminal, on pourrait utiliser le rapport de position du curseur pour obtenir des positions avant / après, par exemple, à partir de

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

et découvrez la largeur des caractères imprimés sur le terminal. Comme il s'agit d'une séquence de contrôle ECMA-48 (ainsi que VT100) prise en charge par presque tous les terminaux que vous êtes susceptible d'utiliser, elle est assez portable.

Pour référence

    CSI Ps n Device Status Report (DSR).
              ...
                Ps = 6 -> Signaler la position du curseur (CPR) [ligne; colonne].
              Le résultat est CSI r; c R

En fin de compte, l'émulateur de terminal détermine la largeur imprimable, en raison de ces facteurs:

  • les paramètres régionaux affectent la façon dont une chaîne peut être formatée, mais la série d'octets envoyée au terminal est interprétée en fonction de la configuration du terminal (notant que certaines personnes diront qu'elle doit être UTF-8, tandis que d'autre part portabilité est la caractéristique demandée dans la question).
  • wcswidthseul ne dit pas comment les combinaisons de caractères sont gérées; POSIX ne mentionne pas cet aspect dans la description de cette fonction.
  • certains caractères (dessin au trait par exemple) que l'on pourrait considérer comme allant de simple largeur sont (en Unicode) "largeur ambiguë", compromettant la portabilité d'une application utilisant wcswidthseule (voir par exemple le Chapitre 2. Configuration de Cygwin ). xtermpar exemple, il est possible de sélectionner des caractères à double largeur pour les configurations nécessaires.
  • pour gérer autre chose que des caractères imprimables, vous devez vous fier à l'émulateur de terminal (sauf si vous souhaitez simuler cela).

Les API Shell appelant wcswidthsont prises en charge à des degrés divers:

Celles-ci sont plus ou moins directes: simuler wcswidthdans le cas de Perl, appeler le runtime C depuis Ruby et Python. Vous pouvez même utiliser des malédictions, par exemple, à partir de Python (qui gérerait la combinaison de caractères):

  • initialiser le terminal à l'aide de setupterm (aucun texte n'est écrit à l'écran)
  • utiliser la filterfonction (pour les lignes simples)
  • dessinez le texte au début de la ligne avec addstr, en vérifiant les erreurs (au cas où il serait trop long), puis la position de fin
  • s'il y a de la place, ajustez la position de départ.
  • appeler endwin(qui ne devrait pas faire un refresh)
  • écrire les informations résultantes sur la position de départ sur la sortie standard

Utiliser des malédictions pour la sortie (plutôt que de renvoyer les informations à un script ou d'appeler directement tput) effacerait toute la ligne (la filterlimite à une ligne).

Thomas Dickey
la source
je pense que ce doit être le seul moyen, vraiment. si le terminal ne prend pas en charge les caractères double largeur, peu importe ce qui wcswidth()a à dire sur quoi que ce soit.
mikeserv
En pratique, le seul problème que j'ai eu avec cette méthode est plink, qui se déclenche TERM=xtermmême s'il ne répond à aucune séquence de contrôle. Mais je n'utilise pas de terminaux très exotiques.
Gilles 'SO- arrête d'être méchant'
Merci. mais l'idée était d'obtenir ces informations avant d'afficher la chaîne sur le terminal (pour savoir où l'afficher, c'est une suite à la récente question sur l'affichage d'une chaîne à droite du terminal, j'aurais peut-être dû mentionner que bien que ma vraie question était vraiment de savoir comment accéder à wcswidth à partir du shell). @mikeserv, oui wcswidth () peut se tromper sur la façon dont un terminal spécifique afficherait une chaîne particulière, mais c'est aussi proche que possible d'une solution indépendante du terminal et c'est ce que col / ksh-printf utilise sur mon système.
Stéphane Chazelas
J'en suis conscient, mais wcswidth n'est pas directement accessible, sauf via des fonctionnalités moins portables (vous pouvez le faire en perl, en faisant certaines hypothèses - voir search.cpan.org/dist/Text-CharWidth/CharWidth.pm ) . À propos, la question d'alignement à droite pourrait être (peut-être) améliorée en écrivant la chaîne en bas à gauche, puis en utilisant la position du curseur et les commandes d'insertion pour la déplacer vers le bas à droite.
Thomas Dickey
1
@ StéphaneChazelas - foldest apparemment spécifié pour gérer les caractères multi-octets et de largeur étendue . Voici comment il doit gérer le retour arrière: le nombre actuel de largeur de ligne doit être décrémenté de un, bien que le nombre ne devienne jamais négatif. L'utilitaire de pliage ne doit pas insérer de <newline> immédiatement avant ou après un <backspace>, sauf si le caractère suivant a une largeur supérieure à 1 et entraînerait une largeur de ligne supérieure à la largeur. peut fold -w[num]- être et pr +[num]pourrait être associé en quelque sorte?
mikeserv
5

Pour les chaînes d'une ligne, l'implémentation GNU de wca une option -L(aka --max-line-length) qui fait exactement ce que vous recherchez (à l'exception des caractères de contrôle).

egmont
la source
1
Merci. Je ne savais pas que cela retournerait la largeur d'affichage. Notez que l'implémentation de FreeBSD a également une option -L, le document dit qu'il renvoie le nombre de caractères sur la plus longue ligne, mais mon test semble indiquer que c'est un certain nombre d'octets à la place (pas la largeur d'affichage en tout cas). OS / X n'a ​​pas de -L même si je m'attendais à ce qu'il dérive de FreeBSD.
Stéphane Chazelas
Il semble également gérer tab(suppose que les tabulations s'arrêtent toutes les 8 colonnes).
Stéphane Chazelas
En fait, pour les chaînes de plus d'une ligne, je dirais que cela fait exactement ce que je recherche, car il gère correctement les caractères de contrôle LF .
Stéphane Chazelas
@ StéphaneChazelas: Avez-vous toujours le problème que cela renvoie le nombre d'octets plutôt que le nombre de caractères? Je l'ai testé sur vos données et j'obtiens les résultats que vous vouliez: wc -L <<< 'unix'→ 8,  wc -L <<< 'Stéphane'→ 8 et  wc -L <<< 'もで 諤奯ゞ'→ 11. PS Vous considérez «Stéphane» comme neuf caractères, dont un de largeur nulle? Cela me ressemble à huit caractères, dont l'un est multi-octet.
G-Man dit `` Réintègre Monica '' le
@ G-Man, je faisais référence à l'implémentation de FreeBSD, qui dans FreeBSD 12.0 et un environnement local UTF-8 semble toujours compter les octets. Notez que é peut être écrit en utilisant un caractère U + 00E9 ou un caractère U + 0065 (e) suivi de U + 0301 (combinant l'accent aigu), ce dernier étant celui indiqué dans la question.
Stéphane Chazelas
4

Dans mon .profile, j'appelle un script pour déterminer la largeur d'une chaîne sur un terminal. Je l'utilise lorsque je me connecte sur la console d'une machine où je ne fais pas confiance à l'ensemble du système LC_CTYPE, ou lorsque je me connecte à distance et que je ne peux pas faire confiance LC_CTYPEpour correspondre au côté distant. Mon script interroge le terminal, plutôt que d'appeler n'importe quelle bibliothèque, car c'était tout l'intérêt de mon cas d'utilisation: déterminer l'encodage du terminal.

Ceci est fragile à plusieurs égards:

  • cela modifie l'affichage, donc ce n'est pas une expérience utilisateur très agréable;
  • il y a une condition de concurrence si un autre programme affiche quelque chose au mauvais moment;
  • il se bloque si le terminal ne répond pas. (Il y a quelques années, j'ai demandé comment améliorer cela , mais cela n'a pas été un gros problème dans la pratique, donc je n'ai jamais pu passer à cette solution. Le seul cas que j'ai rencontré d'un terminal qui ne répond pas était un Windows Emacs accédant à des fichiers distants à partir d'une machine Linux avec la plinkméthode, et je l'ai résolu en utilisant la plinkxméthode à la place .)

Cela peut ou non correspondre à votre cas d'utilisation.

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

Le script renvoie la largeur dans son état de retour, tronquée à 100. Exemple d'utilisation:

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- arrête d'être méchant'
la source
Cela m'a été utile (bien que j'aie surtout utilisé votre version condensée ). J'ai rendu son utilisation un peu plus jolie en ajoutant printf "\r%*s\r" $((${#text}+8)) " ";à la fin de cleanup(l'ajout de 8 est arbitraire; il doit être suffisamment long pour couvrir la sortie plus large des anciens environnements locaux mais assez étroit pour éviter un retour à la ligne). Cela rend le test invisible, mais il suppose également que rien n'a été imprimé sur la ligne (ce qui est bien dans un ~/.profile)
Adam Katz
En fait, il ressort d'une petite expérimentation que dans zsh (5.7.1), vous pouvez simplement le faire text="Éé"et ${#text}vous donnera ensuite la largeur d'affichage (j'obtiens 4un terminal non unicode et 2un terminal compatible unicode). Ce n'est pas vrai pour bash.
Adam Katz
@AdamKatz ${#text}ne vous donne pas la largeur d'affichage. Il vous donne le nombre de caractères dans l'encodage utilisé par les paramètres régionaux actuels. Ce qui est inutile pour mon objectif puisque je veux déterminer l'encodage du terminal. C'est utile si vous voulez la largeur d'affichage pour une autre raison, mais elle n'est pas précise car tous les caractères n'ont pas une unité de largeur. Par exemple, la combinaison des accents a une largeur de 0, et les idéogrammes chinois ont une largeur de 2.
Gilles 'SO- arrête d'être méchant'
Ouais, bon point. Cela peut répondre à la question de Stéphane mais pas à votre intention initiale (c'est d'ailleurs ce que je voulais faire aussi, donc j'ai adapté votre code). J'espère que mon premier commentaire vous a été utile, Gilles.
Adam Katz
3

Eric Pruitt a écrit une implémentation impressionnante de wcwidth()et wcswidth()dans Awk disponible sur wcwidth.awk . Il fournit principalement 4 fonctions

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

wcscolumns()tolère également les caractères non imprimables.

$ 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

J'ai ouvert un problème concernant la gestion des TAB car il wcscolumns($'My sign is\t鼠鼠')devrait être supérieur à 14. Mise à jour: Eric a ajouté la fonction wcsexpand()pour étendre les TAB aux espaces:

$ 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
la source
1

Pour développer les conseils sur les solutions possibles en utilisant colet ksh93dans ma question:

Utiliser le colfrom bsdmainutilssur Debian (peut ne pas fonctionner avec d'autres colimplémentations), pour obtenir la largeur d'un seul caractère non-contrôle:

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

Exemple:

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

Étendu pour une chaîne:

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

Utilisation ksh93de printf '%Ls':

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

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

Utilisation perlde Text::CharWidth:

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