浏览器之文本复制问题小记

2,066 阅读8分钟

问题背景

当在浏览器上复制的时候会发生什么?
web开发会为了方便用户,帮他们推广分享,自动写入广告推广语之类的剪贴板内容的需要。在github比较容易找到的库:

copy-to-clipboard的使用方式比较便捷,用户也只有纯文本的复制需求,项目也就选用这个。

import copy from 'copy-to-clipboard'
copy('第一行\n第二行')

但是用户在分享微信的时候遇到一个问题:chrome上项目的复制功能,在windows桌面微信聊天窗口粘贴的时候,内容都挤在一起没有换行,像是上面的文本为直接变成【第一行第二行】,而macOs是OK的。虽然后面发现copy其实是有配置参数,可以设置为text/plain来避免这个问题,但是为什么一定得设置?毕竟其他地方全都使用正常,就只有windows微信客户端存在问题

在windows才会产生换行符丢失的问题,自然觉得是类Unix和windows上关于换行符的差异。但是一个大众的库应该会考虑到这种情况,而copy-to-clipboard库却是没有发现相关的内容处理。

而通过py来查看剪贴板内容,可以发现换行符\r\n其实存在的,而在像系统记事本、vscode等工具上,都是可以粘贴出来换行符的,和微信一样基于duilib的钉钉也是ok的。

import win32clipboard
import win32con

def printCopyText():
        win32clipboard.OpenClipboard()
        print(win32clipboard.GetClipboardData(win32con.CF_TEXT))
        win32clipboard.CloseClipboard()

printCopyText() 
# 对“我是第一行\n我是第二行”使用copy-to-clipboard复制
# 输出结果:
# b'\xb5\xda\xd2\xbb\xd0\xd0\r\n\xb5\xda\xb6\xfe\xd0\xd0'

所以目前存在如下疑问:

  • 换行符\n是怎么变成\r\n
  • 许多软件粘贴换行符正常,而text/plain才能让微信客户端处理好换行符

copy-to-clipboard原理

import copy from 'copy-to-clipboard'
copy('第一行\n第二行', { format:'text/plain' })

如果没有配置text/plaincopy-to-clipboard文档说默认是用text/html

copy-to-clipboard实现复制的过程主要是由以下几步:

  1. 新建一个span,设置textContent为需要复制的内容,添加样式如white-space:pre

textContent表示一个节点及其后代的文本内容。有个与textContent很类似的属性是innerText,这两者的明显差别是:

  1. innerText获取的是渲染之后的文本,也就是说display: none的内容是得不到的,也就会触发页面的重绘,所以比textContent会更耗性能。如果元素本身没有被渲染,跟textContent是一样的 innerHtml也挺类似,但与前面俩有明显区别就是innerText和textContent是不会解析html的,可以有效避免XSS攻击(但是script标签也可以被XSS)。把textContent换成 innerText,在微信的粘贴却是可以正常显示换行的。
  1. 生成range加入selection来实现选中span
  2. 如果配置传入了format参数,就会拦截span的copy事件回调,调用回调事件的clipboardData来设置如text/plain的样式。
  3. 执行execCommand('copy')

总体流程就是模拟用户select了一个span标签上的内容,如果有传入自定义配置format,就拦截默认行为,手动设置clipboardData。最后执行execCommand默认这个复制行为的启动,这里也就解释了为什么传入text/plain就可以了。但也没有反映出为什么默认行为不行

上面之所以在默认formattext/html重点突出文档说,是因为这不是实现的text/html,而是它认为浏览器的默认行为是text/html
同时这里没有发现对系统换行符差异的考虑。

clipboardjs原理

而我们用clipboardjs的复制源码来替换copy-to-clipboard却是不会存在换行问题,它是这样实现的:

  1. 创建一个textarea
  2. 需要复制的文本置为textareavalue
  3. 新建selectiontextareavalue长度选区,执行execCommand

这里既不需要clipboardData来设置复制文本的格式,同时也没有系统换行符的处理。

execCommand('copy')的原理

copy-to-clipboardclipboardjs的步骤来看,对换行符进行的处理更有可能在最后的execCommand模拟复制这一步,因为写入剪贴板会在这一步,接着应该在chromium找关于execCommand的相关执行代码。

1. execCommand各种命令的执行配置

// third_party\blink\renderer\core\editing\commands\editor_command.cc
// 对于execCommand其他具体指令的了解也可以在这个文件找到相应的配置
 static const EditorInternalCommand kEditorCommands[] = {
 	//...
   {
       EditingCommandType::kCopy, 
       ClipboardCommands::ExecuteCopy, 
       Supported,
       ClipboardCommands::EnabledCopy, 
       StateNone, 
       ValueStateOrNull,
       kNotTextInsertion,
       ClipboardCommands::CanWriteClipboard
    },
     // ...
 }

在配置文件中找到相应的执行动作:ExecuteCopy

2. ExecuteCopy

// third_party\blink\renderer\core\editing\commands\clipboard_commands.cc
// execCommand('copy')相应的执行动作
bool ClipboardCommands::ExecuteCopy(LocalFrame& frame,
                                    Event*,
                                    EditorCommandSource source,
                                    const String&) {
    // ...
    if (EnclosingTextControl(
          frame.Selection().ComputeVisibleSelectionInDOMTree().Start())) {
        frame.GetSystemClipboard()->WritePlainText(frame.SelectedTextForClipboard(),
                                                   GetSmartReplaceOption(frame));
        frame.GetSystemClipboard()->CommitWrite();
        return true;
    }
    
    
    WriteSelectionToClipboard(frame);
    return true;
    //..
}

textControl

首先存在明显的关于selection的判断条件:

EnclosingTextControl(
          frame.Selection().ComputeVisibleSelectionInDOMTree().Start())

从变量定义推测大致逻辑,可以大概整理出如下要点:

  1. ComputeVisibleSelectionInDOMTree指选区的可视化部分开头,即排除掉display:none,visibility: hidden
  2. 判断selection开始位置是否属于textControl类型

chromium 定义textarea和部分input为textControl类型

  1. WritePlainText写入纯文本内容,CommitWrite提交这次修改

SelectedTextForClipboard是对选区内文本的获取:

  • SetEmitsImageAltText - 提取image设置的alt内容。
  • SetSkipsUnselectableContent - 忽略不可选的内容,如user-select: none的节点不会出现在粘贴内容上。
String FrameSelection::SelectedTextForClipboard() const {
  return ExtractSelectedText(
      *this, TextIteratorBehavior::Builder()
                 .SetEmitsImageAltText(
                     frame_->GetSettings() &&
                     frame_->GetSettings()->GetSelectionIncludesAltImageText())
                 .SetSkipsUnselectableContent(true)
                 .SetEntersTextControls(true)
                 .Build());
}

非textContronl

当选区可视节点的起始位置不属于textControl类型的话,就直接调用了WriteSelectionToClipboard方法。

void ClipboardCommands::WriteSelectionToClipboard(LocalFrame& frame) {
  const KURL& url = frame.GetDocument()->Url();
  const String html = frame.Selection().SelectedHTMLForClipboard();
  String plain_text = frame.SelectedTextForClipboard();
  frame.GetSystemClipboard()->WriteHTML(html, url,
                                        GetSmartReplaceOption(frame));
  ReplaceNBSPWithSpace(plain_text);
  frame.GetSystemClipboard()->WritePlainText(plain_text,
                                             GetSmartReplaceOption(frame));
  frame.GetSystemClipboard()->CommitWrite();
}

textControl明显的区别在于,非textControl在既WritePlainText写入纯文本的同时,也用WriteHTML写入了html内容。 这里开始反映出了clipboardjs和copy-to-clipboad的默认行为在本质上的区别了。

3. WritePlainText

// third_party\blink\renderer\core\clipboard\system_clipboard.cc
void SystemClipboard::WritePlainText(const String& plain_text,
                                     SmartReplaceOption) {
  // TODO(https://crbug.com/106449): add support for smart replace, which is
  // currently under-specified.
  String text = plain_text;
#if defined(OS_WIN)
  ReplaceNewlinesWithWindowsStyleNewlines(text);
#endif
  clipboard_->WriteText(NonNullString(text));
}

// third_party\blink\renderer\core\clipboard\clipboard_utilities_win.cc
void ReplaceNewlinesWithWindowsStyleNewlines(String& str) {
  DEFINE_STATIC_LOCAL(String, windows_newline, ("\r\n"));
  StringBuilder result;
  for (unsigned index = 0; index < str.length(); ++index) {
    if (str[index] != '\n' || (index > 0 && str[index - 1] == '\r'))
      result.Append(str[index]);
    else
      result.Append(windows_newline);
  }
  str = result.ToString();
}

到了这里,换行符的处理就破案了,浏览器本身会判断所处的系统来决定\r\n的替换。

总结

execCommand('copy')的流程可以总结为:

  1. 判断selection的开始节点为textControl类型(textarea和部分input组件)复制获取selection的纯文本。像图片获取alt,不可选择区域忽略不计。
  2. 判断selection的开始节点不为textControl类型,同样是用SelectedTextForClipboard获取selection的文本,同时写入html样式的内容,此时剪贴板的一份数据下,有两种不一样类型的解释。

剪贴板对象存在不同的格式内容,而粘贴信息的窗口对于各种格式有不同的优先级获取,像是复制一段网页的文本,在富文本编辑器就会优先获取html内容显示出来,而在聊天窗口却是显示的文本。docs.microsoft.com/en-us/windo…

这时再回到前面关于executeCopy的判断分支可以发现clipboardjscopy-to-clipboad这两个库是不同逻辑的。

  1. clipboardjstextarea是属于textControl,直接就复制selection的文本内容。
  2. copy-to-clipboard是以span为载体,当前的剪贴板对象存在两种解释-text(CF_TEXT)html(CF_HTML)

在粘贴的窗口优先获取CF_TEXT的情况下,两个库粘贴的结果是一致的。
而在window微信客户端复制粘贴产生问题,则可能是chrome写入html内容所设置的剪切板格式,相比于文本,优先被其匹配。
找了段查看剪切板内容的代码看看CF_HTML内容,clipboardjs复制操作的剪切板html内容是空的,而copy-to-clipboard的html有内容。

python引入的win32好像不支持直接读取这个格式,但是win32官方标准格式却有定义CF_HTML。

有了上面的经验,做个小实验,往剪贴板同时写入CF_TEXT和CF_HTML,然后在window微信客户端粘贴。得到的实验结果是:window微信客户端会读取CF_HTML的内容,而其他一开始跟它比较的工具如钉钉等,则是读取文本。
因此开头提到的换行问题的原因在于:


windows客户端在读取剪切板的时候优先获取CF_HTML的内容,而HTML内容的换行符是用\n,直接导致了换行失效。而前面提到的textContent换成innerText就没问题,是因为innerText获取的是渲染后的结果,其剪切板上html的换行符是<br>

番外

navigator.clipboard.writeText

execCommand来实现复制是比较传统的方法了,同时也在废弃中的了,新版的剪贴板操作依赖于暴露在全局对象上的navigator.clipboard,而writeText方法可以实现自定义输入想要复制的文本内容

版本

setTimeout(()=>{
    navigator.clipboard.writeText('第一行\n第二行')
}, 2000)

// 需要focus在html文档上才可以执行

而这个方式写入剪贴板也跟execComand差不多,不会存在换行符的兼容性问题。

可以看到新版api多了PermissionStatus权限判断,会向用户弹起授权窗口。
也有异步操作避免在复制内容过多的情况下,线程阻塞导致页面卡住的问题。

// third_party\blink\renderer\modules\clipboard\clipboard_promise.cc
void ClipboardPromise::HandleWriteTextWithPermission(PermissionStatus status) {
  DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
  if (!GetExecutionContext())
    return;
  if (status != PermissionStatus::GRANTED) {
    script_promise_resolver_->Reject(MakeGarbageCollected<DOMException>(
        DOMExceptionCode::kNotAllowedError, "Write permission denied."));
    return;
  }

  SystemClipboard* system_clipboard = GetLocalFrame()->GetSystemClipboard();
  system_clipboard->WritePlainText(plain_text_);
  system_clipboard->CommitWrite();
  script_promise_resolver_->Resolve();
}

另外chrome提供的write也可以支持二进制内容的复制。