Ncurses无法输出指定数量的宽字符(关于宽字符所需的列数)

3
在下面的程序中,我尝试使用ncurses输出十行每行十个Unicode字符。循环的每次迭代会从三个Unicode字符的数组中选择一个随机字符。然而,我遇到的问题是ncurses不总是会每行写入十个字符...这有点难以解释,但如果您运行该程序,也许您会看到这里和那里有空格。有些行将包含十个字符,有些只有九个,有些只有八个。此时,我不知道自己在做错了什么。
我在Ubuntu20.04.1计算机上运行此程序,并使用默认GUI终端。
#define _XOPEN_SOURCE_EXTENDED 1
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <ncurses.h>

#include <locale.h>
#include <time.h>

#define ITERATIONS 3000
#define REFRESH_DELAY 720000L
#define MAXX 10
#define MAXY 10
#define RANDOM_KANA &katakana[(rand()%3)]
#define SAME_KANA &katakana[2]

void show();

cchar_t katakana[3];
cchar_t kana1;
cchar_t kana2;
cchar_t kana3;

int main() {
  setlocale(LC_ALL, "");
  srand(time(0));

  setcchar(&kana1, L"\u30d0", WA_NORMAL, 5, NULL);
  setcchar(&kana2, L"\u30a6", WA_NORMAL, 4, NULL);
  setcchar(&kana3, L"\u30b3", WA_NORMAL, 4, NULL);
  katakana[0] = kana1;
  katakana[1] = kana2;
  katakana[2] = kana3;
  
  initscr();
  for (int i=0; i < ITERATIONS; i++) {
    show();
    usleep(REFRESH_DELAY);
  }
}

void show() {
  for (int x=0; x < MAXX; x++) {
    for (int y = 0; y < MAXY; y++) {
      mvadd_wch(y, x, RANDOM_KANA);
    }
  }
  refresh();
  //getch();
}

1个回答

1
TL;DR:基本问题在于片假名(和许多其他Unicode字符)通常被称为“双宽字符”,因为它们在等宽终端字体中占用两个列。
因此,如果您将バ放置在显示的第0列,则需要将下一个字符放置在第2列,而不是第1列。这不是您正在执行的操作;您正在尝试将下一个字符放置在第1列,部分重叠了バ,这不仅是ncurses库的视角,也是用于显示的终端仿真器的未定义行为。
因此,您应该更改该行。
      mvadd_wch(y, x, RANDOM_KANA);

      mvadd_wch(y, 2*x, RANDOM_KANA);

注意考虑假名占据两列的事实。这将告诉ncurses将每个字符放在它应该在的列上,避免重叠问题。如果这样做,您的屏幕显示为整洁的10x10矩阵。
请注意,“宽度”的使用(即显示字符的宽度)与C概念“宽字符”(wchar_t)几乎没有关系,后者是存储字符所需的字节数。非英语拉丁字母和希腊语、西里尔字母、阿拉伯语、希伯来语和其他字母表中的字符显示在单列中,但必须存储在wchar_t或多字节编码中。
阅读下面的长答案时,请记住这种区别。
此外,将这些字符称为“双倍宽度”是欧洲中心主义的;从亚洲书写系统(和Unicode标准)的角度来看,东亚字符(包括表情符号)被归类为“半角”或“全角”(或“正常宽度”),因为普通字符是(视觉上)宽字符。
问题确实如您所描述,但细节取决于终端。不幸的是,似乎无法在没有屏幕截图的情况下说明问题,因此我包含了一张图片。这是我手头有的两个终端仿真器中的样子;第二屏显示后显示控制台(因为,正如我们将看到的那样,第一个屏幕总是按预期显示)。左侧是KDE的Konsole;右侧是gnome-terminal。大多数终端仿真器更类似于gnome-terminal,但并非全部。

Two terminal emulators showing misplaced characters

在这两种情况下,您都可以看到不整齐的右边界,但有一个区别:左侧每行有十个字符,但其中一些似乎被错放了。在某些行上,一个字符重叠在前一个字符上,使该行向左移动。在右边,重叠的字符不会显示,因此有些行少于十个字符。但是,在这些行上显示的字符显示相同的半字符偏移量。
问题在于片假名都是“双宽度”字符;也就是说,它们占用了两个相邻的终端单元。我在截图中留下了我的提示(我很少这样做),这样您就可以看到片假名占用与两个拉丁字符相同的空间。
现在,您正在使用mvadd_wch在提供的屏幕坐标处显示每个字符。但是,您提供的大多数屏幕坐标都是不可能的,因为它们会强制双宽字符重叠。例如,您将每行的第一个字符放置在列0中;它占用列0和1(因为它是双宽)。然后,您将下一个字符放置在同一行的第1列,重叠第一个字符。
这是未定义的行为。在大多数应用程序中,第一个屏幕上实际发生的情况可能是可以接受的:由于ncurses不会尝试备份输出一半的双倍宽字符,每个字符都在同一行上紧跟前一个字符输出,因此在第一个屏幕上,片假名完美地排列,每个字符占据两个位置。所以视觉效果很好,但有一个潜在的问题:ncurses将片假名记录为在列0、1、2、3...,但实际上字符在列0、2、4、6...。
当您使用下一个10x10块覆盖第一个屏幕时,这个问题就变得可见了。由于ncurses记录了每个行和列上的字符,这使它能够通过不显示未更改的字符来优化mvadd_wch,这在随机块中偶尔发生,在大多数ncurses应用程序中经常发生。但是,当然,虽然它不必显示已经显示的字符,但它确实需要将下一个字符放置在它应该占据的列上。因此,它需要输出一个光标移动代码。但是,由于字符实际上没有显示在ncurses认为它们所在的列上,它没有计算正确的移动代码。
以第二行为例:ncurses已经确定在列0不需要更改字符,因为它没有改变。然而,你要求在列1显示的字符已经发生了变化。所以ncurses输出一个“向右移动一个字符”的控制台代码,以便在列1写入第二个字符,重叠先前在列0和列2的字符。如屏幕截图所示,Konsole试图显示重叠,而gnome-terminal则擦除了被重叠的字符。(重叠字符是未定义的行为,因此任何一种都是合理的。)它们两个都在列1显示第二个字符。
好的,这就是长而可能令人困惑的解释。
而立即的解决方案在这个答案的开头。但这可能不是完整的解决方案,因为这可能只是你最终程序的高度简化版本。很可能你的真实程序需要以不那么简单的方式计算列数。你需要了解每个输出字符的实际列宽,并使用该信息来计算正确的位置。
有可能你只知道每个字符的宽度。(例如,如果所有字符都是片假名或拉丁字母,那就很容易)。但通常情况下你并不确定,所以你可能会发现向C库询问每个字符占用多少列很有用。你可以使用wcwidth函数来实现这一点。(详情请参见链接,或在控制台上尝试man wcwidth。)
但这里有一个重要的警告: wcwidth将告诉您存储在当前语言环境中的字符宽度。在Unicode语言环境中,结果始终为0、1或2,对于包含在语言环境中的字符,对于不对应于语言环境具有信息的字符代码,则为-1。0用于大多数组合重音以及不移动光标的控制字符,2用于东亚全角字符。
这些都很好,但C库不会与终端模拟器协商。(没有办法做到这一点,因为终端模拟器是一个不同的程序;实际上,它甚至可能不在同一台计算机上。) 因此,库必须假设您已经使用与配置区域设置相同的信息配置了终端模拟器。(我知道这有点不公平。"你"可能只安装了一个Linux发行版,所有的配置都是由组合成分发的软件的各种黑客完成的。他们也没有相互协调。)
大多数时候这样做是有效的。但总会有一些字符的宽度配置不正确。通常,这是因为该字符在终端模拟器使用的字体中,但在区域设置中不被视为有效字符;然后wcwidth返回-1,并且调用者需要猜测要使用哪个宽度。不正确的猜测会导致类似于本答案讨论的问题。因此,您可能会遇到偶尔的故障。
如果您这样做了(或者即使您只想探索一下自己的语言环境),您可以使用this earlier SO answer中的工具和技术。
自Unicode 9以来,除了其他可以改变字符呈现方式的上下文规则之外,还有一个控制字符可以强制后面的字符为全角。因此,现在甚至无法确定字符的列宽,而不看上下文并且了解比您想要知道的更多关于Unicode东亚宽度规则。这使得wcwidth比以前更不通用。

只是为了确保我理解得正确。你的意思是必须区分在显示器上绘制字形所需的物理空间和存储字符编码点所需的八位组数之间的区别? 因此,我的程序中的错误只是图形呈现问题吗? 表示字符所需的水平空间量?因此,通过以我所做的方式重叠假名,根本没有危险发生分段错误,也就是说,写入数据到我不允许访问的内存区域? - Rafael X Villalobos
1
@rafael:显然,字符需要的字节数和像素数之间存在差异。“M”比“i”宽得多,但它们都只需要一个字节(8位);此外,“i”的代码更大。这两个概念处于不同的语义领域。但这并不意味着您的代码一定是安全的,因为未定义的行为是未定义的。可以想象,在某些实现ncurses API的情况下,重叠的字符会导致灾难,因为它会强制代码进入无效的控制流程。但实际的ncurses实现没有这个问题。 - rici
1
而且如果它出现了,很可能会被认为是一个bug。因此最糟糕的情况可能只是显示混乱。但由于人类实现中存在的一个bug,混乱的显示可能会带来真正的问题:我们非常难以区分我们看到的和实际存在的东西。(详见Daniel Kahneman的一本书。值得一读。)区分的纪律是程序员的良好实践;没有它,调试可能会更加困难。 - rici
1
@rafael:最后,不要突破未定义行为的边界。一旦意识到这是可能的,就修复它。如果不这样做,它将会回来缠着你。 - rici
1
如果我没记错的话,一个被定义行为的事情是在显示器的最后一列写入全角字符。即使这意味着留下最后一列空白,显示器也必须换行。在屏幕区域内换行也是同样的情况。因此,天真地编写长字符串应该是可以的,但是如果这很重要,你很容易失去自己的位置。而且我们还没有涉及组合字符... :-( - rici

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接