Dapatkan lebar tampilan dari serangkaian karakter

15

Apa yang paling dekat dengan cara portabel untuk mendapatkan lebar layar (setidaknya pada terminal (yang menampilkan karakter dalam lokal saat ini dengan lebar yang benar)) dari serangkaian karakter dari skrip shell.

Saya terutama tertarik pada lebar karakter non-kontrol tetapi solusi yang memperhitungkan karakter kontrol akun seperti backspace, carriage return, tabulasi horizontal juga diterima.

Dengan kata lain, saya mencari shell API di sekitar wcswidth()fungsi POSIX.

Perintah itu harus kembali:

$ 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

Satu bisa menggunakan ksh93's printf '%<n>Ls'yang memperhitungkan lebar karakter untuk padding untuk <n>kolom, atau colperintah (dengan misalnya printf '++%s\b\b--\n' <character> | col -b) untuk mencoba dan mendapatkan itu, ada Teks :: CharWidth perlmodul setidaknya, tetapi ada pendekatan yang lebih langsung atau portabel.

Itu kurang lebih merupakan tindak lanjut dari pertanyaan lain yang tentang menampilkan teks di sebelah kanan layar yang Anda perlu memiliki informasi itu sebelum menampilkan teks.

Stéphane Chazelas
sumber

Jawaban:

7

Dalam emulator terminal, seseorang dapat menggunakan laporan posisi kursor untuk mendapatkan posisi sebelum / sesudah, misalnya dari

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

dan temukan seberapa lebar karakter yang dicetak di terminal. Karena itu adalah urutan kontrol ECMA-48 (dan juga VT100) yang didukung oleh hampir semua terminal yang mungkin Anda gunakan, ini cukup portabel.

Sebagai referensi

    CSI Ps n Device Status Report (DSR).
              ...
                Ps = 6 -> Laporkan Posisi Kursor (CPR) [baris; kolom].
              Hasilnya adalah CSI r; c R

Pada akhirnya, terminal emulator menentukan lebar yang dapat dicetak, karena faktor-faktor ini:

  • pengaturan lokal mempengaruhi cara string dapat diformat, tetapi serangkaian byte yang dikirim ke terminal diinterpretasikan berdasarkan bagaimana terminal dikonfigurasikan (mencatat bahwa beberapa orang akan berpendapat bahwa itu harus UTF-8, sementara di sisi lain portabilitas adalah fitur yang diminta dalam pertanyaan).
  • wcswidthsendiri tidak memberitahu bagaimana menggabungkan karakter ditangani; POSIX tidak menyebutkan aspek ini dalam deskripsi fungsi itu.
  • beberapa karakter (gambar garis misalnya) yang mungkin diterima orang sebagai lebar tunggal adalah (dalam Unicode) "lebar ambigu", merusak portabilitas aplikasi menggunakan wcswidthsendiri (lihat misalnya Bab 2. Menyiapkan Cygwin ). xtermmisalnya memiliki ketentuan untuk memilih karakter lebar ganda untuk konfigurasi yang diperlukan ini.
  • untuk menangani apa pun selain karakter yang dapat dicetak, Anda harus mengandalkan emulator terminal (kecuali jika Anda ingin mensimulasikan itu).

Panggilan Shell API wcswidthdidukung ke berbagai tingkatan:

Itu kurang lebih langsung: simulasi wcswidthdalam kasus Perl, memanggil runtime C dari Ruby dan Python. Anda bahkan bisa menggunakan kutukan, misalnya, dari Python (yang akan menangani penggabungan karakter):

  • inisialisasi terminal menggunakan setupterm (tidak ada teks tertulis di layar)
  • gunakan filterfungsi (untuk satu baris)
  • menggambar teks di awal baris dengan addstr, memeriksa kesalahan (jika terlalu lama), dan kemudian untuk posisi akhir
  • jika ada ruang, sesuaikan posisi awal.
  • panggilan endwin(yang seharusnya tidak melakukan a refresh)
  • tulis informasi yang dihasilkan tentang posisi awal ke keluaran standar

Menggunakan kutukan untuk output (daripada memberi makan informasi kembali ke skrip atau menelepon langsung tput) akan menghapus seluruh baris ( filtertidak membatasi ke baris).

Thomas Dickey
sumber
Saya pikir ini harus menjadi satu-satunya cara, sungguh. jika terminal tidak mendukung karakter double-lebar, maka tidak masalah apa yang wcswidth()harus dikatakan tentang apa pun.
mikeserv
Dalam praktiknya, satu-satunya masalah yang saya miliki dengan metode ini adalah plink, yang menetapkan TERM=xtermmeskipun tidak menanggapi urutan kontrol apa pun. Tapi saya tidak menggunakan terminal yang sangat eksotis.
Gilles 'SO- stop being evil'
Terima kasih. tetapi idenya adalah untuk mendapatkan informasi itu sebelum menampilkan string pada terminal (untuk mengetahui di mana menampilkannya, itu adalah tindak lanjut dari pertanyaan terakhir tentang menampilkan string di kanan terminal, mungkin saya seharusnya menyebutkan bahwa meskipun pertanyaan saya sebenarnya adalah benar-benar tentang cara mendapatkan wcswidth dari shell). @ mikeserv, ya wcswidth () mungkin salah tentang bagaimana terminal tertentu akan menampilkan string tertentu, tapi itu sedekat yang Anda bisa dengan solusi independen terminal dan itulah yang digunakan col / ksh-printf pada sistem saya.
Stéphane Chazelas
Saya sadar akan hal itu, tetapi wcswidth tidak dapat diakses secara langsung kecuali melalui fitur yang kurang portabel (Anda dapat melakukan ini dalam perl, dengan membuat beberapa asumsi - lihat search.cpan.org/dist/Text-CharWidth/CharWidth.pm ) . Pertanyaan perataan kanan dapat (mungkin) ditingkatkan dengan menulis string ke kiri bawah dan kemudian menggunakan posisi kursor dan kontrol masukkan untuk menggesernya ke kanan bawah.
Thomas Dickey
1
@ StéphaneChazelas - foldtampaknya dispesifikasikan untuk menangani karakter multi-byte dan extended width . Begini caranya menangani backspace: Hitungan lebar garis saat ini harus dikurangi oleh satu, meskipun hitungan tidak akan pernah menjadi negatif. Utilitas lipat tidak boleh memasukkan <newline> segera sebelum atau setelah <backspasi> apa pun, kecuali karakter berikut memiliki lebar lebih besar dari 1 dan akan menyebabkan lebar garis melebihi lebar. mungkin fold -w[num]dan pr +[num]bisa digabungkan entah bagaimana?
mikeserv
5

Untuk string satu-baris, implementasi GNU wcmemiliki opsi -L(alias --max-line-length) yang melakukan apa yang Anda cari (kecuali karakter kontrol).

egmont
sumber
1
Terima kasih. Saya tidak tahu itu akan mengembalikan lebar layar. Perhatikan bahwa implementasi FreeBSD juga memiliki opsi -L, dokter mengatakan itu mengembalikan jumlah karakter dalam garis terpanjang, tetapi pengujian saya tampaknya mengindikasikan itu sejumlah byte sebagai gantinya (bukan lebar tampilan di tas apa pun). OS / X tidak memiliki -L meskipun saya berharap itu berasal dari FreeBSD.
Stéphane Chazelas
Tampaknya menangani tabjuga (mengasumsikan tab berhenti setiap 8 kolom).
Stéphane Chazelas
Sebenarnya, untuk string lebih dari satu baris, saya akan mengatakan itu juga melakukan apa yang saya cari, karena di dalamnya menangani karakter kontrol LF dengan benar .
Stéphane Chazelas
@ StéphaneChazelas: Apakah Anda masih memiliki masalah bahwa ini mengembalikan jumlah byte daripada jumlah karakter? Saya mengujinya pada data Anda dan mendapatkan hasil yang Anda inginkan: wc -L <<< 'unix'→ 8,  wc -L <<< 'Stéphane'→ 8, dan  wc -L <<< 'もで 諤奯ゞ'→ 11. PS Anda menganggap "Stéphane" sebagai sembilan karakter, salah satunya adalah lebar nol? Bagiku seperti delapan karakter, salah satunya multi-byte.
G-Man Mengatakan 'Reinstate Monica'
@ G-Man, saya merujuk pada implementasi FreeBSD, yang di FreeBSD 12.0 dan lokal UTF-8 tampaknya masih menghitung byte. Perhatikan bahwa é dapat ditulis menggunakan satu karakter U + 00E9 atau karakter U + 0065 (e) diikuti oleh U + 0301 (menggabungkan aksen akut), yang terakhir adalah yang ditunjukkan dalam pertanyaan.
Stéphane Chazelas
4

Di saya .profile, saya memanggil skrip untuk menentukan lebar string pada terminal. Saya menggunakan ini ketika masuk pada konsol mesin di mana saya tidak mempercayai set-sistem LC_CTYPE, atau ketika saya login jarak jauh dan tidak bisa percaya LC_CTYPEuntuk mencocokkan sisi remote. Skrip saya menanyakan terminal, daripada memanggil perpustakaan apa pun, karena itulah inti dari kasus penggunaan saya: tentukan pengkodean terminal.

Ini rapuh dalam beberapa cara:

  • itu memodifikasi tampilan, jadi itu bukan pengalaman pengguna yang sangat bagus;
  • ada kondisi lomba jika program lain menampilkan sesuatu pada waktu yang salah;
  • terkunci jika terminal tidak merespons. (Beberapa tahun yang lalu saya bertanya bagaimana memperbaikinya , tetapi dalam praktiknya tidak banyak masalah sehingga saya tidak pernah beralih ke solusi itu. Satu-satunya kasus yang saya temui dari terminal yang tidak merespons adalah sebuah Windows Emacs yang mengakses file jarak jauh dari mesin Linux dengan plinkmetode ini, dan saya menyelesaikannya dengan menggunakan plinkxmetode itu .)

Ini mungkin atau mungkin tidak cocok dengan kasus penggunaan Anda.

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

Script mengembalikan lebar dalam status pengembaliannya, dipangkas menjadi 100. Contoh penggunaan:

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 'SANGAT berhenti menjadi jahat'
sumber
Ini membantu saya (meskipun saya kebanyakan menggunakan versi ringkas Anda ). Saya membuat penggunaannya sedikit lebih cantik dengan menambahkanprintf "\r%*s\r" $((${#text}+8)) " "; pada akhir cleanup(menambahkan 8 adalah sewenang-wenang; perlu cukup lama untuk menutupi output yang lebih luas dari lokal yang lebih tua tetapi cukup sempit untuk menghindari pembungkus garis). Ini membuat tes tidak terlihat, meskipun juga mengasumsikan tidak ada yang dicetak pada garis (yang baik-baik saja dalam a ~/.profile)
Adam Katz
Sebenarnya, ini muncul dari eksperimen kecil bahwa di zsh (5.7.1) Anda hanya bisa melakukan text="Éé"dan kemudian ${#text}akan memberi Anda lebar layar (saya dapatkan 4di terminal non-unicode dan 2di terminal yang sesuai dengan unicode). Ini tidak benar untuk bash.
Adam Katz
@AdamKatz ${#text}tidak memberi Anda lebar layar. Ini memberi Anda jumlah karakter dalam pengkodean yang digunakan oleh lokal saat ini. Yang tidak berguna untuk tujuan saya karena saya ingin menentukan pengkodean terminal. Ini berguna jika Anda menginginkan lebar layar karena alasan lain, tetapi tidak akurat karena tidak setiap karakter memiliki lebar satu unit. Misalnya, menggabungkan aksen memiliki lebar 0, dan ideogram Cina memiliki lebar 2.
Gilles 'SO-stop being evil'
Ya, poin bagus. Mungkin memuaskan pertanyaan Stéphane tetapi bukan maksud asli Anda (yang sebenarnya juga ingin saya lakukan, jadi saya mengadaptasi kode Anda). Semoga komentar pertamaku bermanfaat bagimu, Gilles.
Adam Katz
3

Eric Pruitt menulis implementasi mengesankan wcwidth()dan wcswidth()dalam Bahasa Inggris yang tersedia di wcwidth.awk . Ini terutama menyediakan 4 fungsi

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

di mana wcscolumns()juga mentolerir karakter yang tidak dapat dicetak.

$ 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

Saya membuka masalah menanyakan tentang penanganan TAB karena wcscolumns($'My sign is\t鼠鼠')harus lebih besar dari 14. Pembaruan: Eric menambahkan fungsi wcsexpand()untuk memperluas TAB ke spasi:

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

Untuk memperluas petunjuk tentang kemungkinan solusi menggunakan coldan ksh93dalam pertanyaan saya:

Menggunakan coldaribsdmainutils pada Debian (mungkin tidak bekerja dengan colimplementasi lain ), untuk mendapatkan lebar karakter non-kontrol tunggal:

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

Contoh:

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

Diperpanjang untuk string:

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

Menggunakan ksh93'sprintf '%Ls':

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

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

Menggunakan perl's Text::CharWidth:

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