likes
comments
collection
share

mac和win字体导致空格长度的差异与解决

作者站长头像
站长
· 阅读数 2

背景知识

首先我们需要知道,不同的字体(font-family),其对字符宽度的设置是不一样,不单单体现在同一个字符所占的宽度不一样,还体现在中英文符号的所占宽度的不一样。

总而言之,当我们设置了某种字体,在某台电脑上调整好格式啥的,可能到另外一台电脑上,由于使用的字体不一样,导致格式就会发生变化。

这种问题,你应该在使用office软件的时候更加深刻吧,或者是你在windows上做好的文档,放在mac os上,就变脸了。这些场景,都能让你能够更好地了解我所说的话。

问题

我现在有这么一个场景,我们用pre标签,设置了contenteditable属性,如:

<pre contenteditable="true">

各部门:
    为了适应公司发展需求,经公司研究决定,作如下人事任免:任命同志为公司副总经理,全面负责公司工作;xxx同志为总工程师,负责工程部工作。
特此通知。
</pre>

我相信这段代码不论在mac上还是在win上的页面上显示,虽然不能说一模一样,但是整体差异并不是很大。

为什么?因为pre标签默认字体是font-family: monospace; 假设我们把字体设置为font-family: FangSong,STFangsong,Microsoft YaHei;等之类的。你再在两个平台上看,会发现,格式发生变化了

最明显的是,空格的变化,在mac上输入一个空格,所占宽度要小于半个字符宽度,而在win上,一个空格差不多半个字符宽度。特别是别人要用空格来做段首缩紧时,这时候这种差异就显得不符合别人预期了。

针对上述问题,我们需要先知道,为什么不设置字体的情况下,默认情况下,使用font-family: monospace;差异不大呢,首先我们需要知道,monospace的特点:

它不是指具体某个字体,是个泛指,泛称,表示的是等宽字体,每个字母都一般宽的字体。具体生效是依据具体浏览器环境而定。

可了解

可以知道它有一个很重要的点“等宽字体”,所以在敲入一个空格时,会大约跟一个字符差不多宽度。

其实这类字体还是有很多,这些字体往往有跨平台的兼容性,所以在设置这类字体,基本没多大问题。更甚者,你设置一个mac和win都支持的字体也没这个问题。

其实上述问题,不单单是表现在pre标签里,其他标签同样会存在这样的问题

小结

导致问题出现的原因:设置了字体,但是设置的字体在不同平台上不一定有,这样的话就会导致不同平台应用了不同字体,而不同字体对各类字符的宽度定义又不一样,就会导致设定好的排版在不同平台上表现不一样。

解决方案

其实上节内容里提及到一个简单粗暴的解决方案:

找一个字体跨平台都适用的,或者一定程度上不设置指定字体使用默认字体应该大部分场景下应该也是可以的。

当然,假设真的有需求是需要指定字体的话,那么如何解决。

这里主要说的是关于空格的处理方案,因为像那些“有形”的字体,实际上只是字体显示不一样,但是排版是“相对”一致的,只有空格,会让排版产生差异。

再把问题描述一下

在指定了字体的情况下,空格在mac os的页面上显示的字符宽度,跟windows系统上显示的字符宽度,可能由于两个平台页面上最终采用的字体不一样(虽然指定了字体,但是不能保证平台上都有这个字体),导致显示得不一样,从而影响排版。

例如,在做段首缩进的时候,我敲入了两个空格,在win上,显示两个空格等于一个中文字符的宽度;在mac上,看图,显然不足一个中文字符宽度。

mac和win字体导致空格长度的差异与解决

(这是win上的)

mac和win字体导致空格长度的差异与解决

(这是mac上的)

所以我们现在要解决的是:我们输入空格所占的字符宽度,要两个平台保持一致,如两端都要输入两个空格等于一个字符宽度。

关键知识

我们要对“空格”做一个认识。

以下为网络资料引用说明,大概了解关键信息即可,对于全部描述是否都正确,有待斟酌。 后面我会说重点。

&nbsp;        
 
它叫不换行空格,全称No-Break Space,它是最常见和我们使用最多的空格,大多数的人可能只接触了&nbsp;,它是按下space键产生的空格。
在HTML中,如果你用空格键产生此空格,空格是不会累加的(只算1个)。
要使用html实体表示才可累加,该空格占据宽度受字体影响明显而强烈。
 
&ensp;        
 
它叫“半角空格”,全称是En Space,en是字体排印学的计量单位,为em宽度的一半。
根据定义,它等同于字体度的一半(如16px字体中就是8px)。
名义上是小写字母n的宽度。此空格传承空格家族一贯的特性:
透明的,此空格有个相当稳健的特性,就是其占据的宽度正好是1/2个中文宽度,而且基本上不受字体影响。
 
&emsp;        
 
它叫“全角空格”,全称是Em Space,em是字体排印学的计量单位,相当于当前指定的点数。
例如,1 em在16px的字体中就是16px。
此空格也传承空格家族一贯的特性:
透明的,此空格也有个相当稳健的特性,就是其占据的宽度正好是1个中文宽度,而且基本上不受字体影响。
 
&thinsp;        
 
它叫窄空格,全称是Thin Space。我们不妨称之为“瘦弱空格”,就是该空格长得比较瘦弱,身体单薄,占据的宽度比较小。它是em之六分之一宽。
 
&zwnj; 
 
它叫零宽不连字,全称是Zero Width Non Joiner,简称“ZWNJ”,是一个不打印字符,放在电子文本的两个字符之间,抑制本来会发生的连字,而是以这两个字符原本的字形来绘制。
Unicode中的零宽不连字字符映射为“”(zero width non-joiner,U+200C),HTML字符值引用为: &#8204;
 
&zwj;
 
它叫零宽连字,全称是Zero Width Joiner,简称“ZWJ”,是一个不打印字符,放在某些需要复杂排版语言(如阿拉伯语、印地语)的两个字符之间,使得这两个本不会发生连字的字符产生了连字效果。
零宽连字符的Unicode码位是U+200D (HTML: &#8205; &zwj;)。
 
此外,浏览器还会把以下字符当作空白进行解析:空格(&#x0020;)、制表位(&#x0009;)、换行(&#x000A;)和回车(&#x000D;)还有(&#12288;)等等。

我总结下重点:

我们平常输入的空格相当于&nbsp;效果,该空格占据宽度受字体的影响且明显;而&ensp表示半角空格,占据宽度为半个中文字符宽度,该宽度基本不受字体影响;&emsp表示全角空格,占据宽度为1个中文字符宽度,该宽度基本不受字体影响;

注意这里说的字符宽度,只是表示在界面上看起来的字符占据了界面空间的宽度,而不是说占据的内存空间,不是指字节!

所以我们输入空格,一般指&nbsp;,就会收到字体的影响,导致上述我们问题的发生。因此,要避免这种差异,我们可以考虑,用半角空格或全角空格替换。

思路

基本思路也是很简单明了,就是当我们输入空格的时候,禁止空格的输入输出到界面上,背地里手动将空格替换成&ensp;&emsp;,输出到界面上显示。

接下来我们需要把思路转化成代码逻辑,看有哪些需要考虑的事情。我们一句句剖析思路:

“当我们按下空格的时候,禁止空格的输入输出到界面上”

按下空格就会触发keydown, keypress, keyup事件,那么哪个事件能阻止我们敲了键盘之后,不往界面上输出字符呢?

只有在keydown, keypresspreventDefault才能实现阻止!

“背地里手动将空格替换成&ensp;&emsp;,输出到界面上显示”

假设我们是在keydown中阻止了字符的输出,那么我们就在这个事件中,利用keyCode判断输入的字符是否是空格键,是的话,那么阻止输出,并手动插入&ensp;&emsp;

怎么插入也是个“好活”,我们需要知道当前光标在哪个位置,然后在这个光标后面的位置插入,但是有个场景,就是在选中一段文本下输入空格或者粘贴的话,也要考虑好位置的问题。以及,也要考虑当用户是直接粘贴一段文本时,也要对粘贴的文本进行空格替换。这部分内容处理起来并没想象中的那么过于简单,写代码的时候需要注意挺多的细节。

获取光标或选区的开始和结束位置的方法有两种:

  1. 通过InputElement的selectionStartselectionEnd
  2. 通过window.getSelection进一步获取选区的信息

很多人可能都用过selectionStartselectionEnd,但是这两个属性是只针对inputtextarea等这种InputElement才有效

但是对于那种设置contenteditable=true的普通元素,如<div contenteditable="true"></div>,这种允许文本输入的容器,使用上述两个属性是获取不到的,所以得用第二种方法window.getSelection,这个方法能够获取页面上所有进行了选择的文本,即就算你不是在文本输入容器中选中了文本,仅仅是选择了纯文字部分,也能通过该方法获取到。

window.getSelection 目前在Firefox, Edge (非 Chromium 版本) 及 Internet Explorer 中,getSelection() 对 <textarea> 及 <input> 元素不起作用。

具体这两种方式怎么插入内容,后面在代码部分,会注释详细解释。

这里我是建议把空格替换成半角空格&ensp;,而不是全角,因为假设你的文本是纯英文或者中英文结合等,那么用全角空格,占两个中文字符,对英文的显示不是很友好。很多情况下,一般而言,一个英文字符会占半个中文字符的宽度,所以用半角会更灵活,既能对应英文又能对应中文(输两个空格即可)。

代码逻辑

按照上述的思路,我们接下来转化成代码来看一下,我们以插入&ensp;为例子。

注意在js中要对字符串进行处理,不能使用&ensp;&emsp;表示半角空格和全角空格,要用其charCode

  • \u2003 :全角空格
  • \u2002 :半角空格
  • \xa0: &nbsp;

如果是直接当成html代码渲染出来,那么用&ensp;&emsp;,例如使用insertNode的时候,innerHTML的时候等;同时,也可以用字符引用来表示:

  • &#x2002; 即半角空格,等同&ensp;,只是换种写法的表示
  • &#x2003; 即全角空格,等同&emsp;,只是换种写法的表示
// 输入空格时替换半角空格输出到界面
// container为文本输入容器
container.addEventListener('keydown', el => {
    const e = el || window.event
    if (e.keyCode === 32) {
        insertCxt('\u2002', container) // 替换空格成半角空格
        e.preventDefault()
    }
}, false)

// 粘贴文本的时候也要替换空格
container.addEventListener('paste', e => {
    const e = el || window.event
    // ClipboardEvent.clipboardData 兼容性很好,但是window.clipboardData网上很多资料都看到,然而我就直接用chrome和火狐测试了发现window并没有这个属性。
    if (!e.clipboardData) {
        return
    }
    e.preventDefault()
    const txt = e.clipboardData.getData('Text')
    if (txt) {
        const pasteContent = txt.replace(/ /g, '\u2002') // 替换空格成半角空格
        insertCxt(pasteContent, container)
    }
}
/**
 * 插入内容
 * @param {String} cxt - 要插入的内容
 * @param {Node} textContainer - 输入文本的容器
 */
function insertCxt (cxt, textContainer) {
    const len = cxt.length
    const { selectionStart, selectionEnd, value } = textContainer
    // selectionStart不存在就表示可能是设置了contenteditable=true的普通元素
    if (selectionStart == null) {
        const selection = window.getSelection()
        const range = selection && selection.rangeCount > 0 && selection.getRangeAt(0)
        // 当前没有选区
        if (!range) {
            return
        }
        const { startContainer, endContainer } = range
        // 判断当前选区是否都是在指定的文本输入容器中
        if (textContainer.contains(startContainer) && textContainer.contains(endContainer)) {
            let frag = null
            range.deleteContents() // 当选择了一段文本,则进行删除
            // 创建文档片段,用于把内容插入到选区中
            // 如果支持createContextualFragment方法,则直接用该方法生成文档片段
            // 两者有啥区别,前者就是直接拿选区作为文档片段的容器,而后者是直接创建一个文档片段容器。
            // 但是它们最终插入到选区对象中的,都只是文档片段里的节点,不包含这个片段本身(即这个所谓的文档片段容器)
            if (range.createContextualFragment) {
                frag = range.createContextualFragment(cxt)
            } else { // 不支持则手动创建
                const cxtNode = document.createTextNode(cxt)
                frag = document.createDocumentFragment()
                frag.appendChild(cxtNode)
            }
            range.insertNode(frag) // 插入内容
            textContainer.normalize() // 由于插入后会让文本节点截成不同的节点了,需要合并成一个文本节点
            range.collapse() // 由于插入后还存在选中区域,用该方法合并选区,只显示一个光标
        }
    } else { // 这里是InputElement元素,文本输入框
        const sValue = value.substring(0, selectionStart) // 选区开始处/光标处,前面的那部分文本
        const eValue = value.substring(selectionEnd) // 选区结束处/光标处,后面的那部分文本
        textContainer.value = sValue + cxt + eValue // 对插入内容进行一个拼接
        textContainer.selectionStart = textContainer.selectionEnd = sValue.length + len // 插入后把光标设定在插入内容后
    }
}

至此,从代码层面上实现了我们的解决方案了。

最后有一点可以提一下,如果你的数据是传给后端需要做啥处理的,如果后端不识别半角全角空格

  • 那么我们需要在传给后端前,要把半角全角空格替换回普通空格;
  • 在接收后端返回的数据展示在界面前,要把普通空格替换成半角/全角空格

以下仅为例子:

// text为文本数据

// 传给后端数据前

// 将1个全角空格替换成2个普通空格
text.replace(/\u2003/g, '  ')
// 将半角空格替换成1个普通空格
text.replace(/\u2002/g, ' ')


// 处理后端返回的数据显示在界面上

// 将2个普通空格替换成1个全角空格
text.replace(/ {2}/g, '\u2003')
// 将1个普通空格替换成1个半角空格
text.replace(/ /g, '\u2002')

其他

顺便记录一下研究过程中了解到的一些小知识。

在mac os上,原生的字体,可能是它更倾向于英文用户吧,它默认的字体对中文的支持好像不太精致,注意我是说精致不是说不好。在输入一个四个空格时会发现它相对于1个中文字符宽度多一丢丢,就是强迫症的人总觉得它对不齐而抓狂哈哈。但是它一个空格就是一个英文字符宽度

reference