折腾一下标点缩进

2.7k 5 mins
... ...

  本篇内容:参考汉字排版规范进行简体中文标点缩进方案设计及代码实现过程。
  本来还想写一下 font-face 相关的,但是标点缩进写起来就已经没完没了了,其它部分也不是新技能写起来没什么热情,所以先把这个坑填上再说吧……   

  搭 blog 拖延了几个月真正行动起来根本不想睡觉!!!从早上 9 点通宵到第二天下午两点睡了四个小时坐在电脑前依然精神抖擞,我看我是躁狂又发作了。
  也挺好,勤勉的魔女胜过怠惰的魔女【

  25/03/14 更新:由于本人测试时发现 chrome 似乎自带标点缩进,于是添加了浏览器检测,当浏览器为 chrome 时不会执行任何操作。部分旧版本 chrome 或手机上可能会显示未缩进的标点,但总比煳在一起强吧!

示例

  缩进前→我:(需要。)一个,「有」『很,多,』」、【标点?】的句子!】【)(
  缩进后→我:(需要。)一个,「有」『很,多,』」、【标点?】的句子!】【)(
  无缩进→我:(需要。)一个,「有」『很,多,』」、【标点?】的句子!】【)(
  (注:最后一行是为自带标点缩进的浏览器演示用的。)

  本篇中所有经过缩进处理的标点符号均用彩色高亮显示,此外由于字体问题,本页面实际使用的缩进方案与笔记中的不同,但思路是一致的。
  另外考虑到方引号并非简体中文规范标点,本站其它页面实际使用的是另一套参考日文标点「半角引号 + 空格」效果排版的标点缩进方案,可能更加适配中文网页,有需要可参考:/scripts/mark-rei.js
  是的我懒得在 github 新开一个仓库了就这么几行东西麻烦死了【

方案设计

  首先把标点符号简单分类:

  • 全角左括号,如 (【《「『
  • 全角右括号,如 )】》」』
  • 左下全角分隔符,如 ,。、
  • 居中全角分隔符,如 :·

  「居中全角分隔符」中的冒号在有些字体里可能会出现在左下,应该说汉字标点规范里冒号就应该在左下,但我的标点用的是日文字体,so【

  然后再将需要解决的标点缩进问题归类如下:

  • 左下标点 + 左括号,如 ,「 ,需要前置标点右侧缩进 1em
  • 左下标点 + 右括号,如 。」 ,需要前置标点右侧缩进 0.5em
  • 右括号 + 左下标点,如 」, ,需要前置标点右侧缩进 0.5em
  • 连续的同向括号,如 )】 ,需要前置标点右侧缩进 0.5em
  • 连续的反向括号,如 」( ,需要前置标点右侧缩进 1em
  • 居中分隔符 + 左括号,如 :「 ,需要前置标点两侧各缩进 0.5em
  • 右括号 + 居中分隔符,如 )· ,需要后置标点两侧各缩进 0.5em

  接下来按处理方式重新分类并编号,为写正则表达式做准备:

  • 方案 A:选中符号 margin-right: -0.5em
    • 左下标点 + 右括号 → A1
    • 右括号 + 右括号 → A1
    • 右括号 + 左下标点 → A2
    • 左括号 + 左括号 → A3
  • 方案 B:选中符号 margin-right: -1em
    • 左下标点 + 左括号 → B
    • 右括号 + 左括号 → B
  • 方案 C:选中符号 margin: 0 -0.5em
    • 居中分隔符 + 左括号 → C1
    • 右括号 + 居中分隔符C2

  整理可得一共需要 5 个正则表达式和 3 个样式 class,接下来进入正式的代码环节⸺

代码实现

  仔细一看虽然是写给自己看的笔记但怎么还是一股浓厚的「教程」味扑面而来……
  好吧,那么我的代码笔记依旧遵照「即使某日我突然变成猫了也能看懂」的原则写【。

正则表达式

  首先为了方便后续维护时添加字符,将标点定义为不同的变量:

1
2
3
4
const markL = '(【《「『',
markR = ')】》」』',
markS = ',。、',
markC = ':·';

  然后开始写正则 A1,这个表达式需要「查找所有在右括号前的左下标点和右括号」:

1
let regexA1 = new RegExp('[' + markS + markR + ']' + '(?=[' + markR + ']' + ')', 'g');

  是不是看得头都大了,没关系我的头也很大。
  如果要查找「所有在 B 之前的 A」,基本的格式是这样的↓

1
2
3
let regex = new RegExp( A(?=B), 'g');
// 也可以写成这样,但下面这种写法拼字符串容易出错↓
let regex = /A(?=B)/g;

  将 AB 替换成需要的内容再用 [方括号] 包起来就得到了 A1,在 A1A2 之间使用 | 可以合并两个正则的选择范围,于是我们得到了以下三个正则:

1
2
3
4
5
6
let regexA = new RegExp(
'[' + markS + markR + '](?=[' + markR + '])' + '|'
+ '[' + markR + '](?=[' + markS + '])' + '|'
+ '[' + markL + '](?=[' + markL + '])', 'g'),
regexB = new RegExp('[' + markS + markR + '](?=[' + markL + '])', 'g'),
regexC1 = new RegExp('[' + markC + '](?=[' + markL + '])', 'g');

  C2 的条件与前面的几条不同,需要查找「所有在 B 之后的 A」,基本的格式是这样的:

1
2
3
let regex = new RegExp( (?<=B)A, 'g');
// 也可以写成这样,但下面这种写法拼字符串容易出错↓
let regex = /(?=B)A/g;

  那么我们的正则式就变成了这样,基本算是完成了:

1
2
3
4
5
6
7
8
let regexA = new RegExp(
'[' + markS + markR + '](?=[' + markR + '])' + '|'
+ '[' + markR + '](?=[' + markS + '])' + '|'
+ '[' + markL + '](?=[' + markL + '])', 'g'),
regexB = new RegExp('[' + markS + markR + '](?=[' + markL + '])', 'g'),
regexC = new RegExp(
'[' + markC + '](?=[' + markL + '])' + '|'
+ '(?<=[' + markR + '])[' +markC + ']', 'g');

缩进样式

  完成正则之后 JS 可以准确地查找到我们需要处理的标点,接下来将它们替换成带有不同 classname 的 span 即可。同样为了方便维护,我们也把不同的 span 定义为变量:

1
2
3
const spanA = '<span class="mark type-a">$&</span>',
spanB = '<span class="mark type-b">$&</span>',
spanC = '<span class="mark type-c">$&</span>';

  其中 $& 是一个特殊占位符,用来表示正则匹配到的字符串。例如使用 regexA 匹配到了 ,代码将它替换成 <span>$&</span> 后便会输出 <span>】</span>
  以及在这里使用带空格的 classname 是为了方便演示,自用可以写得短一点,把三种不同的 span 区分开即可。

  之后用 CSS 为不同的 span 写上缩进样式:

1
2
3
4
5
6
7
8
9
.mark.type-a {
margin-right: -0.5em;
}
.mark.type-b {
margin-right: -1em;
}
.mark.type-c {
margin: 0 -0.5em;
}

执行替换

  注意:本阶段代码依赖 jQuery 2.1.3 运行。
  其实用原生 JS 不是实现不了但是 jQuery 的选择器实在是太香啦⸺

  首先选中所有需要处理的元素:

1
2
let elm = $('p, del, .main-content li, center, .description');
// 分别是正文,删除线,正文部分的列表,居中元素以及侧边栏的描述内容

  然后为每一个元素分别执行替换:

1
2
3
4
5
6
elm.each(function() {
let $this = $(this);
let txt = $this.html();
let i = txt.replace(regexA, spanA).replace(regexB, spanB).replace(regexC, spanC);
$this.html(i);
});

  到这里代码就跑起来了,但扔给 GPT 优化的时候得到评价:「每次调用 html() 和 replace() 都会触发 DOM 更新,尤其是对于大量元素,这会导致性能问题。尽量减少对 DOM 的操作次数,最好是先处理好数据,最后一次性更新 DOM。」
  虽然网页加载速度并没有肉眼可见的变慢,但万一是我的电脑太好了呢【?
  于是我加入 log 测试了一下,在当前页面下需要处理 50 个元素,每个元素又需要执行三次 replace ,那么这个刷新次数就来到了惊人的 150 次……
  GPT 的修改意见是把 replace 的操作封装成函数,我不太理解(这不还是每个 dom 跑三遍吗?),尝试了把 elm 替换成 .main-content, .description(这样就只需要执行 2×3 次替换),结果跑是能正常跑,但 jQuery 总是报错 invalid range in character class 。GPT 表示这个报错是因为正则里有非法字符,那显然不是我的正则里有非法字符(我压根没动过正则啊),只能是 jQuery 本身的代码出现了问题。
  想了一下我也不太希望非行内的代码块中的标点被处理,所以最终的优化结果如下:

1
2
3
4
5
6
7
8
9
10
11
function replaceText(txt) {
return txt.replace(regexA, spanA)
.replace(regexB, spanB)
.replace(regexC, spanC);
}
elm.each(function() {
let $this = $(this);
let txt = $this.html();
let i = replaceText(txt);
$this.html(i);
});

  把 li 改成了 ul ,删除了 del 的查找(因为这个一般被包含在 p 内),最后的调用次数从 50 下降到了 33,我个人感觉算是可以接受。如果不是这种频繁换行和切换文字属性的代码笔记而是普通文章的话调用次数应该只少不多,妥协吧(妥协吧)

参考

  • 格式参考:汉字标准格式
  • Debug 协力:chatGPT(虽然我自己解决不了的 bug 它也一样没解决)