我的博客好像还用了不少奇怪的小 trick 来优化 (hopefully) 排版,在这整理一下,分享出来,也是怕几年(几天)后想不起来这些奇怪的东西是在干什么。
主要是 text justify 相关和标点挤压相关。
我博客的 Markdown 渲染基于 unified,所以下面的一些优化是以 unified 插件(remark / remark-rehype / rehype 插件)的形式实现的。
text justify 相关
在每行的宽度略有差异时,使用两端对齐会让中文排版更加美观,这一般通过 text-align
CSS 属性 的 justify
选项实现。但由于网页排版的不确定性,每行的宽度可能不是略有差异而是有很大差异,这时使用 text justify 就会造成过大的空隙,所以需要避免出现过短的行,或者在有必要时禁用 text justify。
lang="zh-CN" 对 text justify 的影响
这个并不是 trick,是基操,但挺重要的,还是写在这。
如果没有将 HTML 的 lang
属性 设为中文,在 Firefox 109(以及其他版本 / 其他浏览器,但不包括 Chrome 109)中就会按照英文的规则,将连续的汉字视作一个单词,而只在词与词之间增大间距,不改变汉字之间的间距,如下所示:
这是一个设置了 lang="en"
和固定宽度的段落,这是一个 loooooooooooooooong word。
其中,第一行没有两端对齐,而第三行只在逗号后面增加了间距。
如果设置了 lang
则会在汉字间添加间距:
这是一个设置了 lang="zh-CN"
和固定宽度的段落,这是一个 loooooooooooooooong word。
当然,设置 HTML 的 lang
属性还有其他作用,不管怎么样总是得设一个的。
断开过长的行内代码
有时候会遇到一些 veryLoooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongInlineCode
,为了让它不断开,前面的一行就会非常短,justify 后间隙就非常大。当然,这样的问题不局限于行内代码,但正常的英文很少会遇到特别长的单词,代码则经常遇到,所以我选择对代码进行处理(主要是因为不需要分词套 <span>
)。
思路很简单,就是把过长的 :not(pre) > code
通过 <wbr>
或者 word
断开,让它不可断开的部分没那么长。例如:<
、<
……(禁止禁止套娃)。
具体实现:breakLongCode
- 如果按空格分开没有超过 10 个字符的部分,不进行处理;
- 如果按正则表达式的
\b
分开没有超过 12 个字符的部分,则在\b
处插入<wbr>
; - 如果按
\b
以及下划线分开没有超过 15 个字符的部分,则在相应位置插入<wbr>
; - 否则,没救了,直接
word
。- break : break - all
当然这个粗糙的处理方式还是有一些问题,比如从上面的示例就可以看出来,期望结果可能是 <wbr>
/ <code>
不被断开,但实际上会在 <
后 / >
前断开。
UPD:链接也可以用同样的方法进行处理。
在小屏幕上对窄的元素禁用 text justify
有的时候容器宽度太小,即使是正常的普通文本也不适合进行 text justify。
我选择了一个非常简单粗暴的处理方法:认为窄的元素 = 小屏幕上嵌套了多级的元素。
.article-style {
text-align: justify;
}
.article-style > * > * {
text-align: initial;
}
@media (min-width: 40em) {
.article-style > * > * {
text-align: inherit;
}
}
(上面放的是编译出来的 CSS,源码是使用 UnoCSS 的 SCSS。)
一些细节:
- 使用
*
选择器,specificity 很低,便于 override。 - 只选择
> * > *
而非* *
,这样的话嵌套更深的元素依然从 parent 继承text-align
,就可以设置 CSS override 掉一整个子树。(之前我一度以为 katex 不自带行间公式居中,后来发现是被我的 CSS 改掉了 text-align
。) - 使用
inherit
而非justify
进行撤销。
对包含过长行内公式的元素禁用 text justify
除了 行内代码
, 也是常见的过长的不可断元素。这里我采用了一个本文最 dirty 的处理方式:在客户端通过 JavaScript 检测宽度过大的行内公式。
具体实现:TextJustifyFix
- 只修改
text
的元素,如果已经设置为其他对齐方式(例如表格中的居中对齐),就不应修改。- align : justify - 使用了
document
来等待字体全部加载好再检查宽度。. fonts . ready
标点挤压
代码实现:mojikumi
、mojikumi
。
相邻标点的挤压
例如:
具体规则 clreq 中说的比较模糊,可以参考 jlreq。
使用 CSS 去除标点的一半空白
CSS 实现标点挤压有两种方式:
- 使用
letter
去掉右侧空白,- spacing : - 0 . 5em margin
去掉左侧空白。- left : - 0 . 5em - 使用
font
- feature - settings : "halt"
将支持 halt 特性的字体变为半宽。
使用 letter
是有讲究的,比 margin
靠谱,因为字符真的会只占半宽,而不是占全宽但一半和下一个字符重叠,甚至造成 overflow,可能导致滚动条出现。但 letter
不能去掉左侧空白,就只能使用 margin
了。
通过 halt 特性变为半宽是更加靠谱的做法,但不是所有字体都支持这一特性,思源宋体是支持的,霞鹜文楷 不支持。并且,halt 只能变为半宽,不能变成其他宽度。
行首行尾标点的挤压
这里参考了 Han.css 的实现,就是把全宽的标点改成半宽标点和一个通过 ::before
或 ::end
添加的半宽的空格:
- 因为是 pseudo element,所以不影响文本内容(例如复制出来的文本)。
- 因为是空格,所以位于行首或行尾时就会发生空格塌陷。
英文标点的处理
我采用了一个比较简单的判断英文标点的方法:
- 空白一侧(左括号、左引号等是左侧,其他一般是右侧)是空格时是英文标点;
- 右单引号右侧是字母时是撇号。
英文标点一般调为半宽即可,但撇号半宽还是有些太宽了,应该(根据字体而定)调成大约三分之一宽,单引号也可以调窄一点,所以可以特判一下单引号,使用 letter
和 margin
。
给霞鹜文楷添加 halt 特性
因为 添加 halt 特性的 issue 被拒了,我就自己改了一个 标点字体,顺便把引号改成了和思源宋体一致的全宽以方便一起处理(也可以理解为改引号宽度顺便添加 halt 特性,毕竟没有 halt 也可以用 letter
和 margin
凑合一下),具体可以看仓库里的 patch 文件。
关于 chws 特性
chws 特性 可以根据上下文自动挤压连续标点,一眼看上去似乎比 halt 更好用。
思源宋体不支持这一特性,只不过有 chws_tool 可以用来转换。
但是,chws 不支持行首行尾标点挤压,而如果要支持行首行尾挤压,就得把支持连续标点挤压的工作做一遍(因为要知道哪些标点已经被挤成了半宽哪些只在行首行尾挤成半宽),这样一来 chws 就没多大意义了。
Yet another mojikumi?
有一些现成的支持标点挤压的库,例如 heti 和 Han.css。但是我有一些需求:
- 要能 SSG (SSR),不能在客户端运行。最好是能在 remark / rehype 中进行处理。
- 最好是尽量使用 halt 而非
letter
和- spacing margin
。- left - 要能添加一些自定义的规则,比如对单引号特殊处理。
- 我只需要标点挤压,不想要一个 CSS 全家桶,或者带有其他功能的 JS。
现有的库难以同时满足上面这些需求,所以我就自己写了。
本来想写一个通用的库,但通用的逻辑貌似没多少代码,不值得写成一个库。而我的整个 remark 插件又有点 opinionated,可能不太适合做成库(懒得做成库),想用的话可以在遵守 AGPL 3.0 的前提下直接复制(本文提到的其他代码也是一样,当然,如果是两三行的代码片段就不至于 AGPL 了,简单标一下出处就 OK)。
代码中的 Unicode(CJK)的字体
为什么马上要考抽代了你在这更新博客,我也很想知道( 其实是在一个群里看到有人在聊博客的等宽字体,然后看了一眼自己的等宽字体,差点没想起来这个 trick 在干什么。
很多等宽字体是没有 CJK 字形的,在 Windows 的默认字体下,fallback 到 monospace
时中文会显示为宋体
,而一般来说应当是黑体
。所以可以为 Unicode 部分专门指定一个 fallback font,通过指定 unicode
来让 ASCII 字符依然 fallback 到 monospace
。
@font-face {
font-family: "Monospace Unicode Fallback";
src: local("Noto Sans Mono CJK SC"),
local("Source Han Mono SC"),
local("Noto Sans CJK SC"),
local("Source Han Sans SC"),
local("Microsoft YaHei");
unicode-range: U+1000-fffff;
}
:root {
--default-mono-font: ui-monospace, DejaVu Sans Mono, Noto Sans Mono, SFMono-Regular, Menlo, Monaco, Consolas, "Monospace Unicode Fallback", monospace, var(--default-emoji-font);
}
为不同语言设置不同字体
其实我感觉我的做法有点丑(,还好有 UnoCSS 稍微强一点(
- 为每个样式(例如宋/楷)和每个语言分别用一个 CSS 变量记录当前字体。
- 改变样式时修改
font
为这个样式的字体,修改每个语言的字体为这个样式。- family - 改变语言时修改
font
为这个语言的字体,修改每个样式的字体为这个语言。- family
语言通过 lang
属性标识。
详见 commit。