终端中的六像素拼贴画

Background

有一种场景是我们尝试在字符终端中显示某种图形,典型的例子为某些服务器端软件的扫码登录/鉴权,甚至进行媒体图片文件的预览等.我承认这种情况不太常见,但是当遇到这种需求时却很难找到合理的解决方案,当展示如二维码等较为简单的黑白图形,常用的方式是使用ANSI字符或终端颜色控制符进行拼贴,但带来的问题是二维码图形的大小不能受应用程序控制,而是随用户终端字体大小更改,常常出现图案过大或字符块出现间隙等问题. 有没有方法能更精细地控制终端字符的展示呢?

image-20231022151330681

Six Pixels

经过一番调研,发现并没有终端控制符能控制字体大小,但部分兼容的终端模拟器允许应用程序以特殊的字符来以像素的形式渲染位图(Bitmap).最早也是最广泛使用的协议被称为 Sixel. Sixel是六像素(Six Pixels)的简称,意思是Sixel协议以一列竖排的6个像素为单位进行控制,每个像素可单独设置颜色/开启与关闭,当六个像素全为开启状态时,从视觉上看来即为在终端模拟器显示一条短竖线.若干像素点相互拼贴即可组成复杂图案.但是可惜的是目前Sixel的输出大多借助于libsixel这个C Lib库,而Go语言似乎没有对应的Wrapper.考虑到只是显示一个较为简单的黑白二维码图片,我决定根据文档自己手撸一个.

Sixel的核心在于向终端输出特殊的控制符和文本指令来指定像素点的渲染,在 这里这里 可以找到两份相似的文档,粗略地说明了Sixel的大概用法:

  • 输出 \x1bPq来指示终端模拟器进入Sixel模式.其中\x1b 为ESC的键值.在P和Q之间,可以插入一些配置项,格式为 ESC_Q p1; p2; p3; q ,你可以在上述的文档中查找到p1/p2/p3具体的配置内容,但是我建议维持默认值就好,因为实践表明不是所有的终端模拟器都遵守这些配置,为这些配置指定值甚至可能在某些终端上有奇奇怪怪的作用.
  • 输出 \x1b\ 来退出Sixel(注意在字符串中可能需要转义"\\").
  • 可以使用#来创建Sixel中使用的颜色映射(在手册中称为颜色寄存器).如 #0;2;0;0;0 指定0号颜色为RGB(0,0,0).有HLS(1)和RGB(2)两种颜色空间可选.而 #0~ 则是以0号颜色渲染一列6个像素.
  • !5~则代表Repeat,将~字符重复5遍.注意!后跟的数字最多不超过255,否则终端可能不支持.但是可以使用!255!2~来多次重复.

除了以上控制符,Sixel还通过可打印的字符来控制上文所述的像素的开关. 正如之前提到的,Sixel将6个纵向排列的像素视为一组,并可以分别控制这6个像素的开启和关闭.当六个像素全不显示时,使用ASCII 63(?)表示,当六个像素全部显示时,使用ASCII126(~) 表示.因此这六个像素便有 2^6=64种排列方式,分别对应63-126这64个ASCII值.为什么要从63开始呢,这是为了保证64个字符全都落在可打印字符的范围内,防止和其他控制功能冲突.如同文本字符一样,由像素组成的竖线也遵循换行的规则,当一行的内容绘制完毕后,使用\n开启新的一行,但是注意,Sixel还设计了一组控制符来控制换行方式:$表示回车(回到行首)但不换行,可以再次在这一行上以不同的颜色等属性绘制其他形状,实现叠加效果,-则是开启新的一行.

上述就是Sixel的所有规则了,简单的规则设计使大部分工作量都落在了应用程序侧,应用程序要将图片进行渲染,并拆分成多行的像素竖列,再考虑拼接等问题.当然,以我这种较为简单的场景而言,要绘制的图案更多是二维码的方块,我将每个方块设计为6x6像素,即6条像素竖线 #1!6~即可绘制出一个方块,结合二维码编码库即可很简单地渲染出二维码像素图.

终端支持

由于SIxel实际上是应用程序和特定终端模拟器的渲染模块交互的协议,因此支持情况因终端而异.你可以在这个网站中查看具体的终端支持情况.就我常用的平台而言,Windows上似乎没有一个很好的选择,Windows Terminal上的支持看上去很玄学,macOS上iTerm2等第三方终端支持,Terminal.app本身不支持.Linux下GNOME的官方Terminal不支持,KConsole支持,诸如Alacritty等第三方终端也支持. 当然,格外要提到 VSCode的Terminal也支持Sixel,只要开启 "terminal.integrated.experimentalImageSupport": true即可.

那么作为App的开发者,如何查询用户的终端模拟器是否支持Sixel呢? 我们可以使用控制符查询\x1B[c终端支持属性.

  • 首先关闭终端回显功能(需要调用特定平台的Syscall),在Linux Shell编程时可以使用stty -echo命令行工具,在Go语言中可以使用 Term.MakeRaw API.
  • 然后向StdOut中写入 \x1B[c.并从Stdout中读取终端模拟器的回复.通常可以对StdOut的文件操作符使用标准的Read/Write 系统调用来实现.
  • 回复中通常包括一系列ACSII编码字符,如果包含4即代表终端支持Sixel.

    func TestSixelSupport(file *os.File) bool {
        //Send Control Character to Terminal and get response
        //If response contains DCS then Sixel is supported
        if !term.IsTerminal(int(file.Fd())) {
            return false
    
        }
        _, err := file.Write([]byte("\x1B[c"))
        if err != nil {
            return false
        }
        buf := make([]byte, 1024)
        //set echo off
        raw, err := term.MakeRaw(int(file.Fd()))
        defer term.Restore(int(file.Fd()), raw)
        _, err = file.Read(buf)
        if err != nil {
            return false
        }
        for _, b := range string(buf) {
            if b == '4' {
                //Found Sixel Support
                return true
            }
        }
        return false
    }

    image

两种渲染方式的对比如上图所示,实现的代码逻辑开源在 https://github.com/lx200916/ADB_Pair_Go/blob/master/Sixel.go 中,并已经向上游库 qrterminal提出PR.

tag(s): Go
show comments · back · home
Edit with markdown