关于剪贴板的故事—起源于公众号后台的一次探寻

1,346 阅读7分钟

整个事情的起源是这样的。

六月底,我打算重新开始更我停了很久的公众号,因为域名到期和图片自动上传不够便利的原因,我弃用了之前的vscode+markdown preview enhanced插件+qiniu-upload-image插件的写文方案。同时,vscode写markdown的换行总有问题,每次都要到网页转化工具进行大量重调,十分不爽。

在不停的搜索过后,我采用了来自KrisTM博客的Typora+PicGo的方案,解决了markdown编写和粘贴、拖拽图片自动上传(图片存储在Gitee仓库)的问题。但是转化问题还是出现了,博客里对于转化到公众号推文的部分是这样说的。

打开HTML,复制网页上的所有内容,直接粘贴到微信公众号编辑框里即可。

而我在实际操作中发现,不管是复制还是生成html,在粘贴到公众号后台时,均会出现如下情况。

image-20200701133829365

这是什么鬼,说好的直接粘贴就行,结果,就这,就这?猜测应该是公众号后台改版了,这个博客写于2020年3月,才6月底就不能用了,可怕。

于是只能继续使用网页转化工具,Md2AllWeChat Format来进行markdown到公众号推文的转化。在网页上点击复制,然后到公众号后台粘贴,就有了内容。

image-20200701134042534

问题似乎已经解决,但是我的好奇心属实被勾起来了。为什么在网页转化工具上点击复制,粘贴到公众号后台就有样式,而在Typora上复制,或者从其他地方复制,粘贴后都是纯文本呢?

对WeChat Format源码的研究

在实践中,我发现,在网页上点击复制后,不管是粘贴到QQ、Wechat,还是Vscode、Pycharm,都呈现的是纯文本形式,只有复制到公众号后台时,才有样式。我顿时对两个网站的复制位置背后的行为产生了好奇,认为这里面肯定有玄学操作。

image-20200701145700586

为了探寻复制的奥秘,我找到了WeChat Format项目的源码,clone后进行查看。

整个项目基于vue,我在写主vue项目的editor.js找到了比较核心的copyrefreshrenderWeChat等函数,在对应到主页面index.html之后,可以发现,点击复制运行的就是copycopy主要使用的是output区域的内容。

    copy: function () {
      var clipboardDiv = document.getElementById('output')
      clipboardDiv.focus();
      window.getSelection().removeAllRanges();  
      var range = document.createRange(); 
      range.setStartBefore(clipboardDiv.firstChild);
      range.setEndAfter(clipboardDiv.lastChild);
      window.getSelection().addRange(range);  
      try {
        if (document.execCommand('copy')) {
          this.$message({
            message: '已复制到剪贴板', type: 'success'
          })
        } else {
          this.$message({
            message: '未能复制到剪贴板,请全选后右键复制', type: 'warning'
          })
        }
      } catch (err) {
        this.$message({
          message: '未能复制到剪贴板,请全选后右键复制', type: 'warning'
        })
      }
    }

其中document.execCommand('copy')是最主要的一行内容,搜索后得知,这一行实现了Copies the current selection to the clipboard。也就是说,第4至8行实现了window.getSelection()区域的清空,添加clipboardDiv区域的首子节点到尾子节点的所有内容到一个新的range,将这个range添加到window.getSelection()等操作。最后第10行完成复制。

output区域的原始内容为空。

<div id="output" v-html="output">

在选项更改后触发的refresh函数中,output值得到更新,v-htmloutput的内容作为html展现,其值来自renderWeChat函数。

    fontChanged: function (fonts) {
      this.wxRenderer.setOptions({
        fonts: fonts
      })
      this.refresh()
    },
    sizeChanged: function(size){
      this.wxRenderer.setOptions({
        size: size
      })
      this.refresh()
    },
    themeChanged: function(themeName){
      var themeName = themeName;
      var themeObject = this.styleThemes[themeName];
      this.wxRenderer.setOptions({
        theme: themeObject
      })
      this.refresh()
    },
    refresh: function () {
      this.output = this.renderWeChat(this.editor.getValue())
    }

refresh后,document.getElementById('output')也就有了内容。

image-20200701140202999

产生output值的renderWeChat函数,则使用了marked.js实现了从markdown到html的渲染,同时自定义了一个函数来根据样式进行渲染,之后添加脚注。

    renderWeChat: function (source) {
      var output = marked(source, { renderer: this.wxRenderer.getRenderer() })
      if (this.wxRenderer.hasFootnotes()) {
        output += this.wxRenderer.buildFootnotes()
      }
      return output
    }

到这已经非常清楚了,送进剪贴板的内容是html,这个结果并不amazing,我原以为公众号后台定义了新的html标准,而这两个网站可以根据标准进行对应的渲染。但是,结果还是html。那为什么我从其他地方复制的html在粘贴到公众号后台后还是纯文本呢。问题,一定出在剪贴板身上。

对剪贴板的研究

对微软剪贴板的实现稍加搜索。

image-20200701112427351

官方解释称,在剪贴板可以放置超过一个对象,每个代表不同格式的同样数据。联想到剪贴板也可以复制图片、复制文件,那么大概率,html和text,在剪贴板中也是作为不同类型存储的。

接下来,便是要找到一个接口,将剪贴板里的数据拿出来,看看是否和我想的一样。

在搜索中,我发现pyqt可以与剪贴板进行交互,并且支持Html、Text、Image、Url等类型。Python如何获取Windows剪贴板内容并判断类型?-施Sugar的回答-知乎

稍加修改后,写出如下代码。

from PyQt5.QtWidgets import QApplication

app = QApplication([])
clipboard = app.clipboard()


def on_clipboard_change():
    data = clipboard.mimeData()
    if data.hasHtml():
        print(f'html-{data.html()}')
    if data.hasText():
        print(f'text-{data.text()}')
    if data.hasUrls():
        print(f'urls-{data.urls()}')
    if data.hasImage():
        print(f'image-{data.imageData()}')
    if data.hasFormat():
        print(f'format-{data.formats()}')

clipboard.dataChanged.connect(on_clipboard_change)
app.exec()

该函数检测五种类型的数据是否存在,存在的时候进行相应输出。

运行后,当点击WeChat Format网页上的复制时,出现如下内容:

html-<html>
<body>
<!--StartFragment--><h2 style="box-sizing: border-box; margin: 80px 10px 40px; padding: 0px; font-weight: normal; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial; text-align: center; color: rgb(63, 63, 63); line-height: 1.5; font-family: Optima-Regular, Optima, PingFangSC-light, PingFangTC-light, &quot;PingFang SC&quot;, Cambria, Cochin, Georgia, Times, &quot;Times New Roman&quot;, serif; font-size: 22.4px;">99岁,生日快乐</h2><!--EndFragment-->
</body>
</html>
text-99岁,生日快乐

当选择复制一个文件夹时:

text-file:///C:/sssimonyang/projects
urls-[PyQt5.QtCore.QUrl('file:///C:/sssimonyang/projects')]

复制图片时:

text-file:///C:/Users/sssimonyang/Pictures/日用类/头像.jpg
urls-[PyQt5.QtCore.QUrl('file:///C:/Users/sssimonyang/Pictures/日用类/头像.jpg')]
image-<PyQt5.QtGui.QImage object at 0x000001C8D1944C88>

而复制Typora中的html时:

text-<!doctype html>
<html>
<head>
<meta charset='UTF-8'><meta name='viewport' content='width=device-width initial-scale=1'>
------------------------

很显然,在Typora中的复制只添加了剪贴板的text内容,html内容为空,所以在复制到公众号后台时呈现的也是text中的内容。

那,如果我将Typora复制的text强行添加到剪贴板的html里会是什么情况呢。

强行修改剪贴板

首先试一下,强行添加html到剪贴板是否能够成功。

我将在wechat-format点击复制的html写入wechat-format.html,然后用程序读取这个文件添加到剪贴板的html,同时,为了区分html和text,我在两者添加了显然不同的内容。注意,在程序运行前,复制一个无关内容更新掉剪贴板,同时程序运行后不要复制其他内容。

from PyQt5.QtCore import QMimeData
from PyQt5.QtWidgets import QApplication

app = QApplication([])
clipboard = QApplication.clipboard()

with open('wechat-format.html', 'r', encoding='utf-8') as f:
    html = f.read()
data = QMimeData()
data.setHtml(html)
data.setText('庆祝中国gongchandang成立九十九周年,初心不改,99如一') #原话不能通过审核,故修改
clipboard.setMimeData(data)

app.exec()

复制到公众号后台后:

image-20200701121511540

成功了!我第一次实现了自己添加的内容被公众号后台成功解析。

下一步,很显然,把wechat-format.html替换成Typora导出的typora.html

替换过后的运行结果:

image-20200701122012516

???这就非常有意思了,居然粘贴的是text里的内容。

两次运行的唯一区别就是html文件,让我们来看看两个html文件之间有什么区别。

image-20200701122324941

wechat-format.htmltypora.html的区别主要在于,typora.html多了第一行<!doctype html>,以及wechat-format.html多了<!--StartFragment--><!--EndFragment-->的配对注释。

让我们照葫芦画瓢抄一下.

image-20200701122939933

再次运行试试:

image-20200701123014773

成功输出了typora.html里的内容,但是没有样式,考虑到typora.html的样式定义主要在<head>中,而wechat-format.html的样式定义在各个标签中,公众号后台应该直接忽略了<head>

稍微改一下typora.html看看效果。把<head>部分删掉,没用的class删掉,然后添加一个样式color:red;font-size:30px

<html>
<body>
<!--StartFragment-->
<div id='write'>
    <h1 style="color:red;font-size:30px"><a name="99岁生日快乐"></a><span>99岁,生日快乐</span>
    </h1>
</div>
</body>
<!--EndFragment-->
</html>

运行,看看效果:

image-20200701124432064

果然改了html文件就好了。

现在就很清楚了,公众号后台会首先读取html的内容,如果html内容不符合他的要求,那么他就读取text内容。

那么这个要求,到底是什么呢,之前我们主要修改了两部分。把第一部分添加上试一下。

<!doctype html>
<html>
<body>
<div id='write'>
    <h1 style="color:red;font-size:30px"><a name="99岁生日快乐"></a><span>99岁,生日快乐</span>
    </h1>
</div>
</body>
</html>

image-20200701125126409

不行,所以识别大概率第一个标签必须是<html>,我们把<html>撤掉试一下。

<body>
<div id='write'>
    <h1 style="color:red;font-size:30px"><a name="99岁生日快乐"></a><span>99岁,生日快乐</span>
    </h1>
</div>
</body>

image-20200701125126409

不行,加上。

<html>
<body>
<div id='write'>
    <h1 style="color:red;font-size:30px"><a name="99岁生日快乐"></a><span>99岁,生日快乐</span>
    </h1>
</div>
</body>
</html>

image-20200701124432064

OK了!

公众号识别读取的是剪贴板中的html内容,如果html的开头不是<html>,那么它就会使用text中的内容,这也解释了之前为什么如何复制在粘贴后都是纯文本的问题。

最后

既然都搞了剪贴板,不如来测试下QQ、Wechat。

运行之前的代码,然后粘贴。

image-20200701142338769

image-20200701142425148

what?QQ和Wechat居然不一样,QQ用的是text内容,Wechat用的是html,这就是宇宙大厂腾讯吗???

结语

这些研究花了我一晚上的时间,其结果实在是有趣。能够自由设定内容后,未来看有没有python写的markdown转化工具,也自己搞个公众号推文转化工具出来。

今天是建党节,九十九年风雨兼程,生日快乐