satgo1546’s ocean

Any sufficiently primitive magic is indistinguishable from technology.

距离实现精美的代码排版还剩−37年

我想要在网页上做出像CWEB那样的代码排版效果,但同时又能正常复制出和搜索到代码原文。

来自CWEB示例程序Adventure

这本质上只是把代码高亮原本的颜色样式改成加粗、倾斜、打字机字体。!==>=&&显示为¬、≡、≥、∧是近年来经常见到的把连字应用在代码上的技巧。(不过我不打算添加像¬和∧这么语义化的连字,因为这会导致人工视觉识别错误的危险。)即使不考虑连字,符号也需要特制的字体,因为一般的字体中ASCII符号为正文优化,例如-显示为连字符,*在高处。以上这些效果实现起来应该并不困难。

事情果真如此简单的话,也就不会有本文了。

现在流行的JavaScript高亮库有三:highlight.jsPrismShiki。highlight.js和Prism的语法定义都会直接略过代码中通常不作高亮的部分,包括标识符和(highlight.js的场合)符号,以减少元素数量。Shiki是个需要WASM的重量级库,因为原本的库并非面向网页设计,较早版本只能直接指定具体色值,而直到现在也不能正常通过CSS直接指定样式,而必须用变量迂回指定颜色,很不符合Web平台精神。

标识符没有span包裹的话,就无法设其为斜体。Prism中可以把所有文字都设置为斜体,然后把有span的部分重设为正体。这是我在为salenzo.readthedocs.io编写样式时采用的方法,当时相当痛苦地调了好久,才把C系语言的样式调到位。highlight.js就不行了,因为标识符和符号都没有span,不可能区分样式。

来自《并并集:一种很新的数据结构(迫真)》

把这些样式搬来,换成Latin Modern字体,字母部分就基本解决了。接下来尝试定制带连字的符号字体。

说到制作字体就不得不提活全家软件FontForge,卡,慢,丑,崩,bug。好多年前我用过那么几次,对它的印象极差。可没办法,功能全面的开源字体制作工具仅此一家。

但现在,我的需求并不复杂。字距调整(kern)和连字(ligature)都是英文字体必备的效果,体验一下功能不那么强大的软件或许也不错,能不用FontForge还是别用了。于是,我打开了Glyphr Studio

导入Latin Modern Math字体时,进度总量上升又下降,然后再上升又下降,最终在仍处于0 of 几千的时候,进度条以我无法理解的奇妙的方式走完了。光导入就花了好几分钟。因为字符数量太多,系统无法自动保存。虽然确实有很多符号,可这是个英文字体啊?不至于吧?

然后我就看到了这一幕:

再见,我回到FontForge了。

唉,FontForge真难用。所有操作都很别扭。按住Shift就会非常卡。VcXsrv里,按一下键等于按三下,用着用着就不知道多出来什么神秘快捷键自动生成的东西,也不知道怎么删掉,越删越多,回退版本解决了问题。交换两个字符后,生成的字体文件依然是交换前的样子,怎么都想不出哪里操作不对,重启解决了问题。这是bug吧,绝对是的吧。

不知道为什么,做出来的字体的减号在特定缩放等级下呈现为空心。

检查了一下字形,也没有发现加号和减号有什么不同。Chrome和Firefox都有这个问题,但大概是因为在Windows上底层都用DirectWrite渲染。“可能是渲染器bug。”我想。又不是看不见,有的人就喜欢搞这么虚的字体。

直到我在导出时看到警告:减号有轮廓重叠。不知道操作了什么导致了复制粘贴,粘贴在原位,两个相同位置和大小的轮廓抵消,渲染出描边是误差。有时在别人的网页上看到这种神秘发虚的字体,读起来很费劲,现在想来,可能是靠误差显示出字的。

我把能用ASCII表示的符号基本都加上了连字,还照着cwebmac.tex中的定义做了++和−−。使用FontDrop!可以看到这些信息。

我还专门做了字体特性:作为运算符,<>应显示为<和>;作为括号,<>应显示为⟨和⟩。CWEB中,C++模板参数的括号也确实如此显示。Prism中,运算符类名为operator,而括号属于punctuation,这样就能区分了……

并不是。

没有斜体矫正确实不协调,但更引人注目的问题是模板参数里的<>确实被Prism标成了运算符。是的,运算符。

倒也不是不能理解,毕竟就连C++编译器也曾有很长一段时间搞不清楚>>是运算符还是括号,基于正则表达式的Prism就更难确定了。我试了一下,就连Vim和装有C/C++扩展的VSCode也未能幸免。

有时候感觉挺无助的。代码高亮也不能乱涂色啊!如果我出本全是未着色代码的书,是不是可以叫它《秘密花园》——想怎么涂就怎么涂?

不过我发现,Vim和VSCode里的TypeScript均无此问题,于是Shiki高亮的TypeScript也无此问题。相比之下,Shiki至少还有正确高亮的希望,于是我开始尝试把这套Prism CSS主题迁移到TextMate。换上Shiki后,站点生成速度变慢了不少,但若如此能开启选择高亮的准确性的分支,那就这样吧。

我没有macOS电脑,Shiki也要求JSON而非plist格式,方便的主题制作方法是在Visual Studio Code的editor.tokenColorCustomizations偏好设置项里的textMateRules属性写规则,使用“开发人员:检查编辑器标记和作用域”命令显示语法栈,配合一个无语法高亮的主题(比如Empty Themes)来预览。用一个包含各种语言代码块的Markdown文件,就能在排除语义化高亮设置干扰的同时,同时预览多种语言。我把我使用的高密度且刁钻的代码片段放在了本站的Hypertext Specimen页面。

TextMate主题不支持设置字体,更不支持设置字体特性。用Shiki中的办法,先设个颜色,输出HTML的时候再代换回CSS类。

TextMate语法提供variable类,可用于应用斜体。运算符被分在了关键字类别下,我觉得很莫名其妙。真正的关键字运算符(如new)在C++里称作keyword.operator.wordlike,而在C#和JavaScript里称作keyword.operator.expression。

以上都是些小问题,但有些语法文件编写不当,导致正确高亮难以进行。JavaScript设定了括号字符本身为meta.brace类,这不符合meta类的含义(参照TextMate语法的说明,应该把整个括号都设为meta.brace),设置它的样式会导致其他正确使用meta.brace类的语言(如Wolfram)被错误高亮。这个问题在7年前的语法文件中就存在,主题针对这个范围实施高亮,现在已改不动了。

该来的还是要来的,重量级的事还是发生了。C++和TypeScript基本调整完毕后,我打开Python看看效果,发现变量名都没有样式。检查发现,Python中的变量名、包名,全都什么类也没有,就只有顶层的source.python!Ruby也是如此。我只得回应以对待Prism一样的手段:设置所有source为斜体,然后把其他记号设为正体。

写CSS时为优先级抓狂,写TextMate主题规则依旧为优先级抓狂。TextMate主题的优先级规则比CSS更进一步,同优先级的规则应用顺序与编写顺序无关,甚至不可预知。样式系统到底为什么不肯把编写顺序当作优先级,非要自己随便定义一套既不好用又不好做的顺序?

  1. Match the element deepest down in the scope e.g. string wins over source.php when the scope is source.php string.quoted.
  2. Match most of the deepest element e.g. string.quoted wins over string.
  3. Rules 1 and 2 applied again to the scope selector when removing the deepest element (in the case of a tie), e.g. text source string wins over source string.
TextMate manual § 13.5 Ranking Matches

TextMate主题不仅继承了CSS打架的优先级策略,还继承了属性不够分离的特点。粗体、斜体、下划线、删除线的组合互斥,这正是所有基于TextMate语法的编辑器都无法正确高亮Markdown中的粗斜体文本的原因。

CSS的问题正在被一点点解决:级联层尝试兼容地允许人为指定优先级;transform、font-feature-settings等不应合并的属性也被拆开了。TextMate已经20年了,依然没人想在代码高亮上做什么新鲜文章,它的问题也就长久留存。翻阅TypeScript语言定义仓库中不会修复的问题列表,可以看到许多因TextMate语法文件限制而无法完成的效果。许多时候,为了计算效率,不得不牺牲显示正确性,这种不正确之处又因其只带来错误染色的微妙性被轻易地无视。

必须承认,语法高亮是一个很小众的领域,但也是一个影响范围广大的领域。无论是历史上的语义化高亮尝试,还是现在集成到LSP中的语义化高亮,编辑器内着色的正确性已经得到了提升。但除着色之外,除编辑器之外,语法高亮本还应能做到的事情还远没有结束。

TextMate跟Prism等本质上并无很大不同,更准确只是因为它被用在编辑器中,得到修正的机会更多而已。一车正则表达式是有极限的,所以CodeMirror 6上了真格解析器Lezer,已然成为语法高亮最准确的编辑器。它能正确解析C++和TypeScript中的>>、JavaScript中的{}/i/i(取决于处于表达式还是语句上下文中,{}可能是空对象或空块,/i/i可能是除法或正则表达式)、Python中的@lambda(作为装饰器的匿名函数)。

不使用Lezer的语法树和增量解析能力的话,只当成高亮库来用也完全没问题,CodeMirror网站上文档中的代码当然就是如此实现高亮的。只是因为语法定义也是真格的,它支持的语言还很少。我经常会在文章中插入一些奇奇怪怪的语言的代码,所以没有考虑Lezer。如果只使用主流语言的话,它也是很好的代码高亮的选择。


一个月后补充:很遗憾,代码高亮 = 乱涂色的现象仍在加剧。

Nue的作者,设计师Tero Piirainen推出了一种高亮代码的全新思路:Glow,高亮引擎核心代码只有300行,不需要为每门语言编写语法文件。这怎么可能??

Glow is different: Rather than parsing language internals, Glow focuses purely on aesthetics and the visual style of your code.

介绍文章中完全没有解释这是如何做到的,但其实猜都应该猜到了。具体来说,实现方式是高亮各种语言中共通的关键字和语法结构,特判常用标记语言,并彻底放弃准确性。

源代码glow.js节选如下。

const MIXED_HTML = ['html', 'jsx', 'php', 'astro', 'dhtml', 'vue', 'svelte', 'hb']
const LINE_COMMENT = { clojure: ';;', lua: '--', python: '#' }
const PREFIXES = { '+': 'ins', '-': 'del', '>': 'dfn' }
const MARK = /(••?)([^]+)\1/g   // ALT + q
const NL = '\n'

const COMMON_WORDS = 'null|true|false|undefined|import|from|async|await|package|begin\
|interface|class|new|int|func|function|get|set|export|default|const|var|let\
|return|for|while|defer|if|then|else|elif|fi|int|string|number|def|public|static|void\
|continue|break|switch|case|final|finally|try|catch|while|super|long|float\
|throw|fun|val|use|fn|my|end|local|until|next|bool|ns|defn|puts|require|each'

// Implement most~50% of words to cover 95% of cases
const SPECIAL_WORDS = {
  cpp: 'cout|cin|using|namespace',
  python: 'None|nonlocal|lambda',
  go: 'chan|fallthrough'
}

我实在是无言以对。这种代码应该出现在IOCCC和JS1k上,而不是npm上!