Uzyskaj szerokość wyświetlania ciągu znaków

15

Jaki byłby najbliższy przenośny sposób uzyskania szerokości wyświetlania (co najmniej na terminalu (takim, który wyświetla znaki w bieżących ustawieniach regionalnych o właściwej szerokości)) ciągu znaków ze skryptu powłoki.

Interesuje mnie przede wszystkim szerokość znaków niekontrolowanych, ale mile widziane są również rozwiązania uwzględniające znaki sterujące, takie jak backspace, powrót karetki, tabulacja pozioma.

Innymi słowy, szukam API powłoki wokół wcswidth()funkcji POSIX.

To polecenie powinno zwrócić:

$ 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

Można użyć ksh93„s printf '%<n>Ls', która bierze pod uwagę szerokość znaków dla dopełnienia do <n>kolumn, lub colpolecenie (z na przykład printf '++%s\b\b--\n' <character> | col -b), aby spróbować i czerpać że istnieje Text :: CharWidth perlmoduł co najmniej, ale są tam bardziej bezpośrednie lub przenośne podejścia.

Jest to mniej więcej kontynuacja tego drugiego pytania, które dotyczyło wyświetlania tekstu po prawej stronie ekranu, dla którego musisz mieć te informacje przed wyświetleniem tekstu.

Stéphane Chazelas
źródło

Odpowiedzi:

7

W emulatorze terminali można użyć raportu pozycji kursora, aby uzyskać pozycje przed / po, np. Z

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

i sprawdź, jak szerokie są znaki drukowane na terminalu. Ponieważ jest to sekwencja kontrolna ECMA-48 (a także VT100) obsługiwana przez prawie każdy terminal, którego prawdopodobnie będziesz używać, jest dość przenośny.

Na przykład

    CSI Ps n Raport o stanie urządzenia (DSR).
              ...
                Ps = 6 -> Zgłoś pozycję kursora (RKO) [wiersz; kolumna].
              Wynik to CSI r; c R

Ostatecznie emulator terminala określa szerokość drukowania, z powodu następujących czynników:

  • ustawienia regionalne wpływają na sposób formatowania łańcucha, ale seria bajtów wysyłanych do terminala jest interpretowana na podstawie konfiguracji terminala (zauważając, że niektórzy twierdzą, że musi to być UTF-8, z drugiej strony przenośność była funkcją wymaganą w pytaniu).
  • wcswidthsam nie mówi, jak traktowane są łączące postacie; POSIX nie wspomina o tym aspekcie w opisie tej funkcji.
  • niektóre znaki (na przykład rysowanie linii), które można uznać za pewną pojedynczą szerokość, są (w Unicode) „niejednoznaczną szerokością”, co podważa przenośność aplikacji używającej wcswidthsamej (patrz na przykład Rozdział 2. Konfiguracja Cygwin ). xtermna przykład ma możliwość wyboru znaków o podwójnej szerokości dla potrzebnych konfiguracji.
  • aby obsłużyć cokolwiek innego niż drukowalne znaki, musisz polegać na emulatorze terminali (chyba że chcesz to zasymulować).

Połączenia API powłoki wcswidthsą obsługiwane w różnym stopniu:

Są one mniej lub bardziej bezpośrednie: symulacja wcswidthw przypadku Perla, wywołanie środowiska wykonawczego C z Ruby i Pythona. Możesz nawet użyć przekleństw, np. Z Pythona (który obsługiwałby łączenie znaków):

  • zainicjuj terminal przy użyciu setupterm (na ekranie nie jest zapisywany żaden tekst)
  • użyj filterfunkcji (dla pojedynczych linii)
  • narysuj tekst na początku linii za pomocą addstr, sprawdzając, czy nie ma błędu (w przypadku, gdy jest za długi), a następnie określ pozycję końcową
  • jeśli jest miejsce, dostosuj pozycję początkową.
  • połączenie endwin(które nie powinno zrobić refresh)
  • zapisz wynikową informację o pozycji początkowej na standardowe wyjście

Użycie przekleństw do wyjścia (zamiast dostarczania informacji z powrotem do skryptu lub bezpośredniego wywoływania tput) wyczyści całą linię ( filterogranicza ją do linii).

Thomas Dickey
źródło
myślę, że to musi być jedyny sposób, naprawdę. jeśli terminal nie obsługuje znaków o podwójnej szerokości, to nie ma większego znaczenia, co wcswidth()ma do powiedzenia o czymkolwiek.
mikeserv
W praktyce jedynym problemem, jaki miałem z tą metodą, jest ustawienie plink, TERM=xtermmimo że nie reaguje ona na żadną sekwencję kontrolną. Ale nie używam bardzo egzotycznych terminali.
Gilles „SO- przestań być zły”
Dzięki. ale pomysł polegał na uzyskaniu tych informacji przed wyświetleniem ciągu na terminalu (aby wiedzieć, gdzie go wyświetlić, jest to kontynuacja ostatniego pytania dotyczącego wyświetlania ciągu po prawej stronie terminala, może powinienem był o tym wspomnieć chociaż moje prawdziwe pytanie dotyczyło tego, jak uzyskać dostęp do wcswidth z powłoki). @ mikeserv, tak wcswidth () może się mylić co do tego, jak konkretny terminal wyświetla określony ciąg, ale jest to tak blisko, jak można dostać się do rozwiązania niezależnego od terminala i to właśnie używa col / ksh-printf w moim systemie.
Stéphane Chazelas
Wiem o tym, ale wcswidth nie jest bezpośrednio dostępny, chyba że za pomocą mniej przenośnych funkcji (możesz to zrobić w Perlu, przyjmując pewne założenia - patrz search.cpan.org/dist/Text-CharWidth/CharWidth.pm ) . Nawiasem mówiąc, pytanie o wyrównanie do prawej można (być może) poprawić, pisząc ciąg w lewym dolnym rogu, a następnie używając pozycji kursora i kontrolek wstawiania, aby przesunąć go w prawy dolny róg.
Thomas Dickey,
1
@ StéphaneChazelas - foldnajwyraźniej jest przeznaczony do obsługi znaków wielobajtowych i znaków o rozszerzonej szerokości . Oto jak powinien obsługiwać backspace: bieżąca liczba szerokości linii zostanie zmniejszona o jeden, chociaż liczba nigdy nie będzie ujemna. Narzędzie fold nie powinno wstawiać <nowej linii> bezpośrednio przed żadnym znakiem <backspace lub po nim, chyba że następujący znak ma szerokość większą niż 1 i spowodowałoby, że szerokość linii przekroczyłaby szerokość. może fold -w[num]i pr +[num]jakoś może zostać w jakiś sposób połączony?
mikeserv
5

W przypadku łańcuchów jednowierszowych implementacja GNU wcma opcję -L(aka --max-line-length), która robi dokładnie to, czego szukasz (z wyjątkiem znaków kontrolnych).

egmont
źródło
1
Dzięki. Nie miałem pojęcia, że ​​zwróci szerokość wyświetlacza. Zauważ, że implementacja FreeBSD ma również opcję -L, dokument mówi, że zwraca liczbę znaków w najdłuższej linii, ale mój test wydaje się wskazywać zamiast tego liczbę bajtów (a nie szerokość wyświetlania w anycase). OS / X nie ma -L, chociaż spodziewałbym się, że wywodzi się z FreeBSD.
Stéphane Chazelas
Wydaje się, że to również obsługuje tab(zakłada tabulatory co 8 kolumn).
Stéphane Chazelas
W rzeczywistości dla ciągów więcej niż jednej linii powiedziałbym, że robi to dokładnie to, czego szukam, ponieważ w tym przypadku poprawnie obsługuje znaki kontrolne LF .
Stéphane Chazelas,
@ StéphaneChazelas: Czy nadal występuje problem polegający na tym, że zwraca liczbę bajtów, a nie liczbę znaków? Przetestowałem go na twoich danych i uzyskałem pożądane wyniki: wc -L <<< 'unix'→ 8,  wc -L <<< 'Stéphane'→ 8 i  wc -L <<< 'もで 諤奯ゞ'→ 11. PS Uważasz, że „Stéphane” to dziewięć znaków, z których jeden ma szerokość zero? Wygląda mi na osiem znaków, z których jeden jest wielobajtowy.
G-Man mówi „Reinstate Monica”
@ G-Man, miałem na myśli implementację FreeBSD, która w FreeBSD 12.0 i ustawieniach regionalnych UTF-8 nadal wydaje się zliczać bajty. Zauważ, że é można zapisać za pomocą jednego znaku U + 00E9 lub znaku U + 0065 (e), a następnie U + 0301 (łącząc ostry akcent), przy czym ten ostatni jest tym, który pokazano w pytaniu.
Stéphane Chazelas
4

W moim .profileprzypadku wywołuję skrypt, aby określić szerokość ciągu na terminalu. Używam tego, logując się na konsoli komputera, na którym nie mam zaufania do zestawu systemowego LC_CTYPE, lub gdy loguję się zdalnie i nie mogę zaufać, LC_CTYPEaby dopasować się do strony zdalnej. Mój skrypt wysyła zapytanie do terminala, zamiast wywoływać jakąkolwiek bibliotekę, ponieważ o to właśnie chodziło w moim przypadku użycia: określ kodowanie terminala.

Jest to kruche na kilka sposobów:

  • modyfikuje wyświetlanie, więc nie jest to zbyt przyjemne doświadczenie użytkownika;
  • występuje wyścig, jeśli inny program wyświetla coś w niewłaściwym czasie;
  • blokuje się, jeśli terminal nie odpowiada. (Kilka lat temu zapytałem, jak to poprawić , ale w praktyce nie stanowiło to większego problemu, więc nigdy nie przeszedłem do przejścia na to rozwiązanie. Jedynym przypadkiem, który spotkałem z terminalem, który nie odpowiada, był Windows Emacs uzyskujący dostęp do zdalnych plików z komputera z systemem Linux plink, a ja rozwiązałem go za pomocą tej plinkxmetody ).

To może, ale nie musi, pasować do twojego przypadku użycia.

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

Skrypt zwraca szerokość w stanie zwrotu przyciętą do 100. Przykładowe użycie:

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- przestań być zły”
źródło
Było to dla mnie pomocne (chociaż najczęściej korzystałem z twojej skróconej wersji ). Uczyniłem jego użycie nieco ładniejszym, dodając printf "\r%*s\r" $((${#text}+8)) " ";na końcu cleanup(dodanie 8 jest arbitralne; musi być wystarczająco długie, aby pokryć szersze wyjście starszych lokalizacji, ale wystarczająco wąskie, aby uniknąć zawijania linii). To sprawia, że ​​test jest niewidoczny, choć zakłada również, że nic nie zostało wydrukowane na linii (co jest w porządku w a ~/.profile)
Adam Katz
Właściwie z małego eksperymentu wynika, że ​​w zsh (5.7.1) możesz po prostu zrobić, text="Éé"a następnie ${#text}podasz szerokość wyświetlania (dostaję się 4w terminalu innym niż Unicode i 2terminalu zgodnym z Unicode). Nie dotyczy to bash.
Adam Katz
@AdamKatz ${#text}nie podaje szerokości wyświetlania. Podaje liczbę znaków w kodowaniu używanych przez bieżące ustawienia regionalne. Co jest dla mnie bezużyteczne, ponieważ chcę ustalić kodowanie terminala. Jest to przydatne, jeśli chcesz wyświetlić szerokość z innego powodu, ale nie jest ona dokładna, ponieważ nie każda postać ma szerokość jednej jednostki. Na przykład łączenie akcentów ma szerokość 0, a chińskie ideogramy mają szerokość 2.
Gilles 'SO - przestań być zły'
Tak, dobra uwaga. To może zaspokoić pytanie Stéphane'a, ale nie twoje pierwotne zamiary (co w rzeczywistości też chciałem zrobić, dlatego dostosowuję twój kod). Mam nadzieję, że mój pierwszy komentarz był dla ciebie pomocny, Gilles.
Adam Katz
3

Eric Pruitt napisał imponującą implementację wcwidth()iw programie wcswidth()Awk dostępną na wcwidth.awk . Zapewnia głównie 4 funkcje

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

gdzie wcscolumns()toleruje również znaki niedrukowalne.

$ 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

Otworzyłem problem z pytaniem o obsługę TAB, ponieważ wcscolumns($'My sign is\t鼠鼠')powinien być większy niż 14. Aktualizacja: Eric dodał funkcję wcsexpand()rozszerzania TAB do spacji:

$ 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
źródło
1

Aby rozwinąć wskazówki dotyczące możliwych rozwiązań za pomocą coliw ksh93moim pytaniu:

Używając colz bsdmainutilsna Debianie (może nie działać z innymi colimplementacjami), aby uzyskać szerokość pojedynczego niekontrolowanego znaku:

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

Przykład:

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

Rozszerzony na ciąg:

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

Korzystanie ksh93z printf '%Ls':

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

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

Korzystanie perlz Text::CharWidth:

stringwidth() {
  perl -MText::CharWidth=mbswidth -le 'print mbswidth shift' "$@"
}
Stéphane Chazelas
źródło