一些博客排版优化小 trick

我的博客好像还用了不少奇怪的小 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 设为 en 的段落在 Firefox 109 中的渲染效果

其中第一行没有两端对齐而第三行只在逗号后面增加了间距

如果设置了 lang="zh-CN" 则会在汉字间添加间距

这是一个设置了 lang="zh-CN" 和固定宽度的段落这是一个 loooooooooooooooong word

上面的 lang 设为 zh-CN 的段落在 Firefox 109 中的渲染效果

当然设置 HTML 的 lang 属性还有其他作用不管怎么样总是得设一个的

断开过长的行内代码

有时候会遇到一些 veryLoooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooongInlineCode为了让它不断开前面的一行就会非常短justify 后间隙就非常大当然这样的问题不局限于行内代码但正常的英文很少会遇到特别长的单词代码则经常遇到所以我选择对代码进行处理主要是因为不需要分词套 <span>

思路很简单就是把过长的 :not(pre) > code 通过 <wbr> 或者 word-break: break-all 断开让它不可断开的部分没那么长例如<code>word<wbr>-<wbr>break<wbr>: <wbr>break<wbr>-<wbr>all</code><code>&lt;<wbr>code<wbr>&gt;<wbr>word<wbr>&lt;<wbr>wbr<wbr>&gt;-&lt;<wbr>wbr<wbr>&gt;<wbr>break<wbr>&lt;<wbr>wbr<wbr>&gt;: &lt;<wbr>wbr<wbr>&gt;<wbr>break<wbr>&lt;<wbr>wbr<wbr>&gt;-&lt;<wbr>wbr<wbr>&gt;<wbr>all<wbr>&lt;/<wbr>code<wbr>&gt;</code>……禁止禁止套娃

具体实现breakLongCode.ts

  1. 如果按空格分开没有超过 10 个字符的部分不进行处理
  2. 如果按正则表达式的 \b 分开没有超过 12 个字符的部分则在 \b 处插入 <wbr>
  3. 如果按 \b 以及下划线分开没有超过 15 个字符的部分则在相应位置插入 <wbr>
  4. 否则没救了直接 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

除了 行内代码行内公式\text{行内公式} 也是常见的过长的不可断元素这里我采用了一个本文最 dirty 的处理方式在客户端通过 JavaScript 检测宽度过大的行内公式

具体实现TextJustifyFix.vue

  • 只修改 text-align: justify 的元素如果已经设置为其他对齐方式例如表格中的居中对齐就不应修改
  • 使用了 document.fonts.ready 来等待字体全部加载好再检查宽度

标点挤压

代码实现mojikumi.tsmojikumi.scss

相邻标点的挤压

例如当然本页其他地方也有一些例子

具体规则 clreq 中说的比较模糊可以参考 jlreq

使用 CSS 去除标点的一半空白

CSS 实现标点挤压有两种方式

使用 letter-spacing 是有讲究的margin-right 靠谱因为字符真的会只占半宽而不是占全宽但一半和下一个字符重叠甚至造成 overflow可能导致滚动条出现letter-spacing 不能去掉左侧空白就只能使用 margin-left

通过 halt 特性变为半宽是更加靠谱的做法但不是所有字体都支持这一特性思源宋体是支持的霞鹜文楷 不支持并且halt 只能变为半宽不能变成其他宽度

行首行尾标点的挤压

这里参考了 Han.css 的实现就是把全宽的标点改成半宽标点和一个通过 ::before::end 添加的半宽的空格

  • 因为是 pseudo element所以不影响文本内容例如复制出来的文本
  • 因为是空格所以位于行首或行尾时就会发生空格塌陷

英文标点的处理

我采用了一个比较简单的判断英文标点的方法

  • 空白一侧左括号左引号等是左侧其他一般是右侧是空格时是英文标点
  • 右单引号右侧是字母时是撇号

英文标点一般调为半宽即可但撇号半宽还是有些太宽了应该根据字体而定调成大约三分之一宽单引号也可以调窄一点所以可以特判一下单引号使用 letter-spacingmargin-left

给霞鹜文楷添加 halt 特性

因为 添加 halt 特性的 issue 被拒了我就自己改了一个 标点字体顺便把引号改成了和思源宋体一致的全宽以方便一起处理也可以理解为改引号宽度顺便添加 halt 特性毕竟没有 halt 也可以用 letter-spacingmargin-left 凑合一下具体可以看仓库里的 patch 文件

关于 chws 特性

chws 特性 可以根据上下文自动挤压连续标点一眼看上去似乎比 halt 更好用

思源宋体不支持这一特性只不过有 chws_tool 可以用来转换

但是chws 不支持行首行尾标点挤压而如果要支持行首行尾挤压就得把支持连续标点挤压的工作做一遍因为要知道哪些标点已经被挤成了半宽哪些只在行首行尾挤成半宽这样一来 chws 就没多大意义了

Yet another mojikumi?

有一些现成的支持标点挤压的库例如 hetiHan.css但是我有一些需求

  • 要能 SSG SSR不能在客户端运行最好是能在 remark / rehype 中进行处理
  • 最好是尽量使用 halt 而非 letter-spacingmargin-left
  • 要能添加一些自定义的规则比如对单引号特殊处理
  • 我只需要标点挤压不想要一个 CSS 全家桶或者带有其他功能的 JS

现有的库难以同时满足上面这些需求所以我就自己写了

本来想写一个通用的库但通用的逻辑貌似没多少代码不值得写成一个库而我的整个 remark 插件又有点 opinionated可能不太适合做成库懒得做成库想用的话可以在遵守 AGPL 3.0 的前提下直接复制本文提到的其他代码也是一样当然如果是两三行的代码片段就不至于 AGPL 了简单标一下出处就 OK

代码中的 UnicodeCJK的字体

为什么马上要考抽代了你在这更新博客我也很想知道 其实是在一个群里看到有人在聊博客的等宽字体然后看了一眼自己的等宽字体差点没想起来这个 trick 在干什么

很多等宽字体是没有 CJK 字形的在 Windows 的默认字体下fallback 到 monospace 时中文会显示为宋体而一般来说应当是黑体所以可以为 Unicode 部分专门指定一个 fallback font通过指定 unicode-range 来让 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